diff --git a/.coveragerc b/.coveragerc index 623e985c8f5..c8e59e55357 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx/__init__.py similarity index 74% rename from homeassistant/components/light/lifx.py rename to homeassistant/components/light/lifx/__init__.py index 945c163435b..7c7bdd4eeea 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx/__init__.py @@ -10,19 +10,24 @@ import asyncio 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__) @@ -35,18 +40,19 @@ BULB_LATENCY = 500 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.""" @@ -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 @@ -104,10 +113,42 @@ class LIFXManager(object): 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] @@ -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) @@ -145,10 +186,10 @@ class LIFXLight(Light): @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): @@ -178,11 +219,21 @@ class LIFXLight(Light): _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.""" @@ -213,13 +264,85 @@ class LIFXLight(Light): @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 + hsbk, changed_color = self.find_hsbk(**kwargs) + _LOGGER.debug("turn_on: %s (%d) %d %d %d %d %d", + self.who, self._power, fade, *hsbk) + + if self._power == 0: + if changed_color: + self.device.set_color(hsbk, None, 0) + self.device.set_power(True, None, fade) + else: + self.device.set_power(True, None, 0) # racing for power status + if changed_color: + self.device.set_color(hsbk, None, fade) + + self.update_later(0) + if fade < BULB_LATENCY: + self.set_power(1) + self.set_color(*hsbk) + + @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: + fade = 0 + + self.device.set_power(False, None, fade) + + self.update_later(fade) + if fade < BULB_LATENCY: + self.set_power(0) + + @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: + 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]) @@ -242,54 +365,7 @@ class LIFXLight(Light): else: kelvin = self._kel - hsbk = [hue, saturation, brightness, kelvin] - _LOGGER.debug("turn_on: %s (%d) %d %d %d %d %d", - self.who, self._power, fade, *hsbk) - - if self._power == 0: - if changed_color: - self.device.set_color(hsbk, None, 0) - self.device.set_power(True, None, fade) - else: - self.device.set_power(True, None, 0) # racing for power status - if changed_color: - self.device.set_color(hsbk, None, fade) - - self.update_later(0) - if fade < BULB_LATENCY: - self.set_power(1) - self.set_color(*hsbk) - - @asyncio.coroutine - def async_turn_off(self, **kwargs): - """Turn the device off.""" - if ATTR_TRANSITION in kwargs: - fade = int(kwargs[ATTR_TRANSITION] * 1000) - else: - fade = 0 - - self.device.set_power(False, None, fade) - - self.update_later(fade) - 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() + return [[hue, saturation, brightness, kelvin], changed_color] def set_power(self, power): """Set power state value.""" diff --git a/homeassistant/components/light/lifx/effects.py b/homeassistant/components/light/lifx/effects.py new file mode 100644 index 00000000000..e2ba0a73534 --- /dev/null +++ b/homeassistant/components/light/lifx/effects.py @@ -0,0 +1,338 @@ +"""Support for light effects for the LIFX light platform.""" +import logging +import asyncio +import random +from os import path + +import voluptuous as vol + +from homeassistant.components.light import ( + DOMAIN, ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_RGB_COLOR, ATTR_EFFECT) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID) +from homeassistant.helpers.service import extract_entity_ids +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +SERVICE_EFFECT_BREATHE = 'lifx_effect_breathe' +SERVICE_EFFECT_PULSE = 'lifx_effect_pulse' +SERVICE_EFFECT_COLORLOOP = 'lifx_effect_colorloop' +SERVICE_EFFECT_STOP = 'lifx_effect_stop' + +ATTR_POWER_ON = 'power_on' +ATTR_PERIOD = 'period' +ATTR_CYCLES = 'cycles' +ATTR_SPREAD = 'spread' +ATTR_CHANGE = 'change' + +# aiolifx waveform modes +WAVEFORM_SINE = 1 +WAVEFORM_PULSE = 4 + +LIFX_EFFECT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_POWER_ON, default=True): cv.boolean, +}) + +LIFX_EFFECT_BREATHE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ + ATTR_BRIGHTNESS: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)), + ATTR_COLOR_NAME: cv.string, + ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), + vol.Coerce(tuple)), + vol.Optional(ATTR_PERIOD, default=1.0): vol.All(vol.Coerce(float), + vol.Range(min=0.05)), + vol.Optional(ATTR_CYCLES, default=1.0): vol.All(vol.Coerce(float), + vol.Range(min=1)), +}) + +LIFX_EFFECT_PULSE_SCHEMA = LIFX_EFFECT_BREATHE_SCHEMA + +LIFX_EFFECT_COLORLOOP_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ + ATTR_BRIGHTNESS: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)), + vol.Optional(ATTR_PERIOD, default=60): vol.All(vol.Coerce(float), + vol.Clamp(min=1)), + vol.Optional(ATTR_CHANGE, default=20): vol.All(vol.Coerce(float), + vol.Clamp(min=0, max=360)), + vol.Optional(ATTR_SPREAD, default=30): vol.All(vol.Coerce(float), + vol.Clamp(min=0, max=360)), +}) + +LIFX_EFFECT_STOP_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_POWER_ON, default=False): cv.boolean, +}) + + +def setup(hass, lifx_manager): + """Register the LIFX effects as hass service calls.""" + @asyncio.coroutine + def async_service_handle(service): + """Internal func for applying a service.""" + entity_ids = extract_entity_ids(hass, service) + if entity_ids: + devices = [entity for entity in lifx_manager.entities.values() + if entity.entity_id in entity_ids] + else: + devices = list(lifx_manager.entities.values()) + + if devices: + yield from start_effect(hass, devices, + service.service, **service.data) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.async_register( + DOMAIN, SERVICE_EFFECT_BREATHE, async_service_handle, + descriptions.get(SERVICE_EFFECT_BREATHE), + schema=LIFX_EFFECT_BREATHE_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle, + descriptions.get(SERVICE_EFFECT_PULSE), + schema=LIFX_EFFECT_PULSE_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle, + descriptions.get(SERVICE_EFFECT_COLORLOOP), + schema=LIFX_EFFECT_COLORLOOP_SCHEMA) + + 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(hass, devices, service, **data): + """Start a light effect.""" + tasks = [] + for light in devices: + tasks.append(hass.async_add_job(light.stop_effect())) + yield from asyncio.wait(tasks, loop=hass.loop) + + if service in SERVICE_EFFECT_BREATHE: + effect = LIFXEffectBreathe(hass, devices) + elif service in SERVICE_EFFECT_PULSE: + effect = LIFXEffectPulse(hass, devices) + elif service == SERVICE_EFFECT_COLORLOOP: + effect = LIFXEffectColorloop(hass, devices) + elif service == SERVICE_EFFECT_STOP: + effect = LIFXEffectStop(hass, devices) + + hass.async_add_job(effect.async_perform(**data)) + + +@asyncio.coroutine +def default_effect(light, **kwargs): + """Start an effect with default parameters.""" + service = kwargs[ATTR_EFFECT] + data = { + ATTR_ENTITY_ID: light.entity_id, + } + if service in (SERVICE_EFFECT_BREATHE, SERVICE_EFFECT_PULSE): + data[ATTR_RGB_COLOR] = [ + random.randint(1, 127), + random.randint(1, 127), + random.randint(1, 127), + ] + data[ATTR_BRIGHTNESS] = 255 + yield from light.hass.services.async_call(DOMAIN, service, data) + + +def effect_list(): + """Return the list of supported effects.""" + return [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_BREATHE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_STOP, + ] + + +class LIFXEffectData(object): + """Structure describing a running effect.""" + + def __init__(self, effect, power, color): + """Initialize data structure.""" + self.effect = effect + self.power = power + self.color = color + + +class LIFXEffect(object): + """Representation of a light effect running on a number of lights.""" + + def __init__(self, hass, lights): + """Initialize the effect.""" + self.hass = hass + self.lights = lights + + @asyncio.coroutine + def async_perform(self, **kwargs): + """Do common setup and play the effect.""" + yield from self.async_setup(**kwargs) + yield from self.async_play(**kwargs) + + @asyncio.coroutine + def async_setup(self, **kwargs): + """Prepare all lights for the effect.""" + for light in self.lights: + yield from light.refresh_state() + if not light.device: + self.lights.remove(light) + else: + light.effect_data = LIFXEffectData( + self, light.is_on, light.device.color) + + # Temporarily turn on power for the effect to be visible + if kwargs[ATTR_POWER_ON] and not light.is_on: + hsbk = self.from_poweroff_hsbk(light, **kwargs) + light.device.set_color(hsbk) + light.device.set_power(True) + + # pylint: disable=no-self-use + @asyncio.coroutine + def async_play(self, **kwargs): + """Play the effect.""" + yield None + + @asyncio.coroutine + def async_restore(self, light): + """Restore to the original state (if we are still running).""" + if light.effect_data: + if light.effect_data.effect == self: + if light.device and not light.effect_data.power: + light.device.set_power(False) + yield from asyncio.sleep(0.5) + if light.device: + light.device.set_color(light.effect_data.color) + yield from asyncio.sleep(0.5) + light.effect_data = None + self.lights.remove(light) + + def from_poweroff_hsbk(self, light, **kwargs): + """The initial color when starting from a powered off state.""" + return None + + +class LIFXEffectBreathe(LIFXEffect): + """Representation of a breathe effect.""" + + def __init__(self, hass, lights): + """Initialize the breathe effect.""" + super(LIFXEffectBreathe, self).__init__(hass, lights) + self.name = SERVICE_EFFECT_BREATHE + self.waveform = WAVEFORM_SINE + + @asyncio.coroutine + def async_play(self, **kwargs): + """Play the effect on all lights.""" + for light in self.lights: + self.hass.async_add_job(self.async_light_play(light, **kwargs)) + + @asyncio.coroutine + def async_light_play(self, light, **kwargs): + """Play a light effect on the bulb.""" + period = kwargs[ATTR_PERIOD] + cycles = kwargs[ATTR_CYCLES] + hsbk, _ = light.find_hsbk(**kwargs) + + # Start the effect + args = { + 'transient': 1, + 'color': hsbk, + 'period': int(period*1000), + 'cycles': cycles, + 'duty_cycle': 0, + 'waveform': self.waveform, + } + light.device.set_waveform(args) + + # Wait for completion and restore the initial state + yield from asyncio.sleep(period*cycles) + yield from self.async_restore(light) + + def from_poweroff_hsbk(self, light, **kwargs): + """Initial color is the target color, but no brightness.""" + hsbk, _ = light.find_hsbk(**kwargs) + return [hsbk[0], hsbk[1], 0, hsbk[2]] + + +class LIFXEffectPulse(LIFXEffectBreathe): + """Representation of a pulse effect.""" + + def __init__(self, hass, lights): + """Initialize the pulse effect.""" + super(LIFXEffectPulse, self).__init__(hass, lights) + self.name = SERVICE_EFFECT_PULSE + self.waveform = WAVEFORM_PULSE + + +class LIFXEffectColorloop(LIFXEffect): + """Representation of a colorloop effect.""" + + def __init__(self, hass, lights): + """Initialize the colorloop effect.""" + super(LIFXEffectColorloop, self).__init__(hass, lights) + self.name = SERVICE_EFFECT_COLORLOOP + + @asyncio.coroutine + def async_play(self, **kwargs): + """Play the effect on all lights.""" + period = kwargs[ATTR_PERIOD] + spread = kwargs[ATTR_SPREAD] + change = kwargs[ATTR_CHANGE] + direction = 1 if random.randint(0, 1) else -1 + + # Random start + hue = random.randint(0, 359) + + while self.lights: + hue = (hue + direction*change) % 360 + + random.shuffle(self.lights) + lhue = hue + + transition = int(1000 * random.uniform(period/2, period)) + for light in self.lights: + if spread > 0: + transition = int(1000 * random.uniform(period/2, period)) + + if ATTR_BRIGHTNESS in kwargs: + brightness = int(65535/255*kwargs[ATTR_BRIGHTNESS]) + else: + brightness = light.effect_data.color[2] + + hsbk = [ + int(65535/359*lhue), + int(random.uniform(0.8, 1.0)*65535), + brightness, + 4000, + ] + light.device.set_color(hsbk, None, transition) + + # Adjust the next light so the full spread is used + if len(self.lights) > 1: + lhue = (lhue + spread/(len(self.lights)-1)) % 360 + + yield from asyncio.sleep(period) + + def from_poweroff_hsbk(self, light, **kwargs): + """Start from a random hue.""" + return [random.randint(0, 65535), 65535, 0, 4000] + + +class LIFXEffectStop(LIFXEffect): + """A no-op effect, but starting it will stop an existing effect.""" + + def __init__(self, hass, lights): + """Initialize the stop effect.""" + super(LIFXEffectStop, self).__init__(hass, lights) + self.name = SERVICE_EFFECT_STOP + + @asyncio.coroutine + def async_perform(self, **kwargs): + """Do nothing.""" + yield None diff --git a/homeassistant/components/light/lifx/services.yaml b/homeassistant/components/light/lifx/services.yaml new file mode 100644 index 00000000000..1b34c54f253 --- /dev/null +++ b/homeassistant/components/light/lifx/services.yaml @@ -0,0 +1,99 @@ +lifx_effect_breathe: + description: Run a breathe effect by fading to a color and back. + + fields: + entity_id: + description: Name(s) of entities to run the effect on + example: 'light.kitchen' + + brightness: + description: Number between 0..255 indicating brightness when the effect peaks + example: 120 + + color_name: + description: A human readable color name + example: 'red' + + rgb_color: + description: Color for the fade in RGB-format + example: '[255, 100, 100]' + + period: + description: Duration of the effect in seconds (default 1.0) + example: 3 + + cycles: + description: Number of times the effect should run (default 1.0) + example: 2 + + power_on: + description: Powered off lights are temporarily turned on during the effect (default True) + example: False + +lifx_effect_pulse: + description: Run a flash effect by changing to a color and back. + + fields: + entity_id: + description: Name(s) of entities to run the effect on + example: 'light.kitchen' + + brightness: + description: Number between 0..255 indicating brightness of the temporary color + example: 120 + + color_name: + description: A human readable color name + example: 'red' + + rgb_color: + description: The temporary color in RGB-format + example: '[255, 100, 100]' + + period: + description: Duration of the effect in seconds (default 1.0) + example: 3 + + cycles: + description: Number of times the effect should run (default 1.0) + example: 2 + + power_on: + description: Powered off lights are temporarily turned on during the effect (default True) + example: False + +lifx_effect_colorloop: + description: Run an effect with looping colors. + + fields: + entity_id: + description: Name(s) of entities to run the effect on + example: 'light.disco1, light.disco2, light.disco3' + + brightness: + description: Number between 0..255 indicating brightness of the effect. Leave this out to maintain the current brightness of each participating light + example: 120 + + period: + description: Duration between color changes (deafult 60) + example: 180 + + change: + description: Hue movement per period, in degrees on a color wheel (default 20) + example: 45 + + spread: + description: Maximum hue difference between participating lights, in degrees on a color wheel (default 30) + example: 0 + + power_on: + description: Powered off lights are temporarily turned on during the effect (default True) + example: False + +lifx_effect_stop: + description: Stop a running effect. + + fields: + entity_id: + description: Name(s) of entities to stop effects on. Leave out to stop effects everywhere. + example: 'light.bedroom'