import asyncio from functools import partial import random from aiolifx.aiolifx import features_map # aiolifx waveform modes WAVEFORM_SINE = 1 WAVEFORM_PULSE = 4 NEUTRAL_WHITE = 3500 def lifx_white(device): """Return true if the device supports neither color nor temperature range.""" max_kelvin = features_map[device.product].get("max_kelvin", None) min_kelvin = features_map[device.product].get("min_kelvin", None) if max_kelvin is not None and min_kelvin is not None: return bool(max_kelvin == min_kelvin) def has_extended_multizone(device): """Return true if the device supports extended multizone messages.""" return features_map[device.product]["extended_multizone"] class PreState: """Structure describing a power/color state.""" def __init__(self, device): self.power = (device.power_level != 0) self.color = list(device.color) if device.color_zones: self.color_zones = device.color_zones.copy() else: self.color_zones = None class RunningEffect: """Structure describing a running effect.""" def __init__(self, effect, pre_state): self.effect = effect self.pre_state = pre_state class AwaitAioLIFX: """Wait for an aiolifx callback and return the message.""" def __init__(self): """Initialize the wrapper.""" self.device = None self.message = None self.event = asyncio.Event() def callback(self, device, message): """Handle responses.""" self.device = device self.message = message self.event.set() async def wait(self, method): """Call an aiolifx method and wait for its response or a timeout.""" self.event.clear() method(callb=self.callback) await self.event.wait() return self.message class Conductor: def __init__(self, loop): self.loop = loop self.running = {} self.lock = asyncio.Lock() def effect(self, device): """Return the effect currently running on a device.""" if device.mac_addr in self.running: return self.running[device.mac_addr].effect else: return None async def start(self, effect, participants): if not participants: return async with self.lock: effect.conductor = self # Restore previous state await self._stop_nolock(participants, effect) # Remember the current state tasks = [] for device in participants: if device.version is None: response = await AwaitAioLIFX().wait(device.get_version) device.product = response.product if not self.running.get(device.mac_addr): tasks.append(asyncio.create_task(AwaitAioLIFX().wait(device.get_color))) if device.color_zones: if has_extended_multizone(device): tasks.append(asyncio.create_task(AwaitAioLIFX().wait(device.get_extended_color_zones))) else: for zone in range(0, len(device.color_zones), 8): tasks.append(asyncio.create_task(AwaitAioLIFX().wait(partial(device.get_color_zones, start_index=zone)))) if tasks: await asyncio.wait(tasks) for device in participants: running = self.running.get(device.mac_addr) pre_state = running.pre_state if running else PreState(device) self.running[device.mac_addr] = RunningEffect(effect, pre_state) # Powered off zones report zero brightness on older multizone devices. Get the real values. if has_extended_multizone(device) is False: await self._fixup_multizone(participants) self.loop.create_task(effect.async_perform(participants)) async def stop(self, devices): async with self.lock: await self._stop_nolock(devices) async def _stop_nolock(self, devices, new_effect=None): tasks = [] for device in devices: tasks.append(self.loop.create_task(self._stop_one(device, new_effect))) if tasks: await asyncio.wait(tasks) async def _stop_one(self, device, new_effect): running = self.running.get(device.mac_addr) if not running: return effect = running.effect index = next(i for i,p in enumerate(effect.participants) if p.mac_addr == device.mac_addr) effect.participants.pop(index) if new_effect and effect.inherit_prestate(new_effect): return del self.running[device.mac_addr] if not running.pre_state.power: device.set_power(False) await asyncio.sleep(0.3) ack = AwaitAioLIFX().wait zones = running.pre_state.color_zones if zones: for index, zone_hsbk in enumerate(zones): apply = 1 if (index == len(zones)-1) else 0 await ack(partial(device.set_color_zones, index, index, zone_hsbk, apply=apply)) else: await ack(partial(device.set_color, running.pre_state.color)) await asyncio.sleep(0.3) async def _fixup_multizone(self, participants): """Temporarily turn on multizone lights to get the correct zone states.""" fixup = [] for device in participants: if device.color_zones and device.power_level == 0: fixup.append(device) if not fixup: return async def powertoggle(state): tasks = [] for device in fixup: tasks.append(asyncio.create_task(AwaitAioLIFX().wait(partial(device.set_power, state)))) await asyncio.wait(tasks) await asyncio.sleep(0.3) # Power on await powertoggle(True) # Get full hsbk tasks = [] for device in fixup: for zone in range(0, len(device.color_zones), 8): tasks.append(asyncio.create_task(AwaitAioLIFX().wait(partial(device.get_color_zones, start_index=zone)))) await asyncio.wait(tasks) # Update pre_state colors for device in fixup: for zone in range(0, len(device.color_zones)): self.running[device.mac_addr].pre_state.color_zones[zone] = device.color_zones[zone] # Power off again await powertoggle(False) class LIFXEffect: """Representation of a light effect running on a number of lights.""" def __init__(self, power_on=True): """Initialize the effect.""" self.power_on = power_on self.conductor = None self.participants = None async def async_perform(self, participants): """Do common setup and play the effect.""" self.participants = participants # Temporarily turn on power for the effect to be visible tasks = [] for device in self.participants: if self.power_on and not device.power_level: tasks.append(self.conductor.loop.create_task(self.poweron(device))) if tasks: await asyncio.wait(tasks) await self.async_play() async def poweron(self, device): hsbk = await self.from_poweroff_hsbk(device) device.set_color(hsbk) device.set_power(True) await asyncio.sleep(0.1) async def async_play(self): """Play the effect.""" return None async def from_poweroff_hsbk(self, device): """Return the color when starting from a powered off state.""" return [random.randint(0, 65535), 65535, 0, NEUTRAL_WHITE] def running(self, device): return self.conductor.running[device.mac_addr] def inherit_prestate(self, other): """Returns True if two effects can run without a reset.""" return False class EffectPulse(LIFXEffect): """Representation of a pulse effect.""" def __init__(self, power_on=True, mode=None, period=None, cycles=None, hsbk=None): """Initialize the pulse effect.""" super().__init__(power_on) self.name = 'pulse' self.mode = mode if mode else 'blink' if self.mode == 'strobe': default_period = 0.1 default_cycles = 10 else: default_period = 1.0 default_cycles = 1 self.period = period if period else default_period self.cycles = cycles if cycles else default_cycles self.hsbk = hsbk # Breathe has a special waveform if self.mode == 'breathe': self.waveform = WAVEFORM_SINE else: self.waveform = WAVEFORM_PULSE # Ping and solid have special duty cycles if self.mode == 'ping': ping_duration = int(5000 - min(2500, 300*self.period)) self.skew_ratio = 2**15 - ping_duration elif self.mode == 'solid': self.skew_ratio = -2**15 else: self.skew_ratio = 0 async def async_play(self): """Play the effect on all lights.""" for device in self.participants: self.conductor.loop.create_task(self.async_light_play(device)) # Wait for completion and restore the initial state on remaining participants await asyncio.sleep(self.period*self.cycles) await self.conductor.stop(self.participants) async def async_light_play(self, device): """Play a light effect on the bulb.""" # Strobe must flash from a dark color if self.mode == 'strobe': device.set_color([0, 0, 0, NEUTRAL_WHITE]) await asyncio.sleep(0.1) # Now run the effect color = await self.effect_color(device) args = { 'transient': 1, 'color': color, 'period': int(self.period*1000), 'cycles': self.cycles, 'skew_ratio': self.skew_ratio, 'duty_cycle': self.skew_ratio, 'waveform': self.waveform, } device.set_waveform(args) async def from_poweroff_hsbk(self, device): """Start with the target color, but no brightness.""" to_hsbk = await self.effect_color(device) return [to_hsbk[0], to_hsbk[1], 0, to_hsbk[2]] async def effect_color(self, device): pre_state = self.running(device).pre_state base = list(pre_state.color) if self.hsbk: # Use the values provided in hsbk (but skip parts with None) return list(map(lambda x,y: y if y is not None else x, base, self.hsbk)) else: # Set default effect color based on current setting hsbk = base if self.mode == 'strobe': # Strobe: cold white hsbk = [hsbk[0], 0, 65535, 5600] elif lifx_white(device) or hsbk[1] < 65536/2: # White: toggle brightness hsbk[2] = 0 if (hsbk[2] > 65536/2 and pre_state.power) else 65535 else: # Color: fully desaturate with full brightness hsbk = [hsbk[0], 0, 65535, 4000] return hsbk class EffectColorloop(LIFXEffect): """Representation of a colorloop effect.""" def __init__(self, power_on=True, period=None, change=None, spread=None, brightness=None, saturation_min=None, saturation_max=None, transition=None): """Initialize the colorloop effect.""" super().__init__(power_on) self.name = 'colorloop' self.period = period if period else 60 self.change = change if change else 20 self.spread = spread if spread else 30 self.brightness = brightness self.saturation = [saturation_min or 52428, saturation_max or 65535] self.transition = transition def inherit_prestate(self, other): """Returns True if two effects can run without a reset.""" return type(self) == type(other) async def async_play(self, **kwargs): """Play the effect on all lights.""" # Random start hue = random.uniform(0, 360) % 360 direction = 1 if random.randint(0, 1) else -1 while self.participants: hue = (hue + direction*self.change) % 360 lhue = hue random.shuffle(self.participants) for device in self.participants: if self.transition is not None: transition = int(1000*self.transition) elif device == self.participants[0] or self.spread > 0: transition = int(1000 * random.uniform(self.period/2, self.period)) if self.brightness is not None: brightness = self.brightness else: brightness = self.running(device).pre_state.color[2] saturation = int(random.uniform(self.saturation[0], self.saturation[1])) hsbk = [ int(65535/360*lhue), saturation, brightness, NEUTRAL_WHITE, ] device.set_color(hsbk, None, transition) # Adjust the next light so the full spread is used if len(self.participants) > 1: lhue = (lhue + self.spread/(len(self.participants)-1)) % 360 await asyncio.sleep(self.period)