LIFX Z Strip Integration - Commands to LED zones not batched (breaks transitions)

Something like this would work:

async def async_main():
    """The main loop."""

    connection = LIFXConnection(IP_ADDR, MAC)
    await connection.async_setup()

    beam: Light = connection.device

    def _callb(device: Light, message: Message):
        """Print each received response."""
        print(message) 

    # we need to get a bunch of values to determine
    # if the device is multizoned otherwise
    # the set_extended_color_zone method doesn't
    # work
    beam.get_version(callb=_callb)
    beam.get_hostfirmware(callb=_callb)
    beam.get_extended_color_zones(callb=_callb)

    ... snipped ...

Though, I love using Rich for this sort of thing. Install it using pip, add import rich to the top of the script and then change print(message) to rich.inspect(message, methods=True) and enjoy the deep-dive inspection of what exactly the Message object looks like.

This works under pyscript:

"""Paint a LIFX beam."""
import asyncio
from random import randint

from aiolifx.connection import LIFXConnection

IP_ADDR="1.2.3.4"
MAC="11:22:33:44:55:66"
TRANSITION_TIME=1

@service
def paint_beam() -> None:
    """Paint colors onto beam."""

    connection = LIFXConnection(IP_ADDR, MAC)
    connection.async_setup()

    connection.device.get_version()
    connection.device.get_hostfirmware()
    connection.device.get_extended_color_zones()

    while connection.device.color_zones is None:
        asyncio.sleep(0)

    new_colors = []
    
    # this is just randomly generating a new hue and brightness for
    # each zone. It should be replaced by your own method for 
    # setting the HSBK for each zone.
    for _ in range(0, len(connection.device.color_zones)):
        new_color = int(randint(0, 359) / 360 * 65535)
        new_brightness = randint(32768, 65535)
        new_colors.append((new_color, 65535, new_brightness, 3500))

    for _ in range(len(new_colors),  82):
        new_colors.append((0, 0, 0, 0))

    connection.device.set_extended_color_zones(new_colors, len(connection.device.color_zones), duration=(TRANSITION_TIME * 1000))

Well luckily I can use requirements.txt and pyscript will just pip install anything I want! Unfortunately all print() methods are still not working inside the loop. Canā€™t even do a simple print(ā€œtestā€). :thinking: The output inside the loop must go somewhere other than the home assistant log.

The random integer script is working and I can for sure adapt this! I am curious why the main loop dropped out of the code?

Pyscript uses its own custom Python interpreter which does odd things. Use log.debug() or log.info() to get stuff into the Home Assistant log. Make sure you set custom_components.pyscript.file.paint_beam to log at the right level too.

Hmm I was using log.warning() and log.debug() inside the loop and they did nothing, outside the loop they would print fine. Not really important just odd behavior.

In other news I was able to send a color command to the light but I do have one minor gripe for you. That is that (0,0,0,0) does not execute through set_extended_color_zones() - I would have expected it to turn that zone off. Instead I had to use (0,0,0,6500).

I also need to be able to send a set_power() command in the event the light is off but Iā€™m getting some aiolifx exceptions from this (even though it does work). I suspect I need to set up an asyncio wait but Iā€™m not exactly sure.

@service
def z_sunrise_test2(Transition=60):
    """Window Sunshine"""

    #init
    Transition = Transition * 60 #transition time should be in minutes
    connection = LIFXConnection(IP_ADDR, MAC)
    connection.async_setup()
    # connection.device.get_version()
    # connection.device.get_hostfirmware()
    connection.device.get_extended_color_zones()
    connection.device.get_power()

    #await connection
    while connection.device.color_zones is None or connection.device.get_power() is None:
        asyncio.sleep(0)

    power = connection.device.get_power()

    #set colors for phase1 in HSBK values of 0 to 65535
    phase1 = [
        (6553, 65535, 1965, 4000), 
        (6553, 65535, 1965, 4000),
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (6553, 65535, 1965, 4000), 
        (6553, 65535, 1965, 4000)
        ]
    # Fill in the remaining empty/non-existant zones
    for _ in range(len(phase1),  82):
        phase1.append((0, 0, 0, 0))

    #if the light is off we need zero transition time, otherwise the light will blast us in the face
    if (power == 0):
        phase1_time = 0 
    else:
        phase1_time = 15
    
    #send command to light
    connection.device.set_extended_color_zones(phase1, len(connection.device.color_zones), duration=(phase1_time * 1000))
    if power==0: connection.device.set_power(True,2000)

Hereā€™s the error:

2023-10-18 08:12:20.003 ERROR (MainThread) [homeassistant] Error doing job: Exception in callback _SelectorDatagramTransport._read_ready()
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/usr/local/lib/python3.11/asyncio/selector_events.py", line 1163, in _read_ready
    self._protocol.datagram_received(data, addr)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 223, in datagram_received
    callb(self, response)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 971, in 
    mycallb = lambda x, y: (mypartial(y), callb(x, y))
                                          ^^^^^^^^^^^
TypeError: 'int' object is not callable
2023-10-18 08:12:20.484 ERROR (MainThread) [homeassistant] Error doing job: Exception in callback _SelectorDatagramTransport._read_ready()
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/usr/local/lib/python3.11/asyncio/selector_events.py", line 1163, in _read_ready
    self._protocol.datagram_received(data, addr)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 223, in datagram_received
    callb(self, response)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 971, in 
    mycallb = lambda x, y: (mypartial(y), callb(x, y))
                                          ^^^^^^^^^^^
TypeError: 'int' object is not callable
2023-10-18 08:12:20.990 ERROR (MainThread) [homeassistant] Error doing job: Exception in callback _SelectorDatagramTransport._read_ready()
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/usr/local/lib/python3.11/asyncio/selector_events.py", line 1163, in _read_ready
    self._protocol.datagram_received(data, addr)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 223, in datagram_received
    callb(self, response)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 971, in 
    mycallb = lambda x, y: (mypartial(y), callb(x, y))
                                          ^^^^^^^^^^^
TypeError: 'int' object is not callable
2023-10-18 08:12:21.584 ERROR (Recorder) [homeassistant] Error doing job: Task exception was never retrieved
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 347, in try_sending
    await event.wait()
  File "/usr/local/lib/python3.11/asyncio/locks.py", line 213, in wait
    await fut
asyncio.exceptions.CancelledError
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 346, in try_sending
    async with asyncio_timeout(timeout_secs):
  File "/usr/local/lib/python3.11/asyncio/timeouts.py", line 111, in __aexit__
    raise TimeoutError from exc_val
TimeoutError
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 354, in try_sending
    callb(self, None)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 971, in 
    mycallb = lambda x, y: (mypartial(y), callb(x, y))
                                          ^^^^^^^^^^^
TypeError: 'int' object is not callable

First, you need to uncomment connection.device.get_version() and connection.device.get_hostfirmware() as those two requests are necessary for aiolifx to know that the device is multizone capable. Without them, the get_extended_color_zones() and set_extended_color_zones() will throw errors (as youā€™ve seen).

Second, aiolifx is a bit weird with its return values, so connection.device.get_power() will almost always return the wrong value. Instead, just check connection.device.power_level which is what get_power() actually updates.

You also donā€™t need to wait for it to be true. You can send the transition regardless and turn it on/off while thatā€™s happening without impacting that. Itā€™s one of the things that makes me stick to LIFX. :slight_smile:

Hereā€™s a modified version of my original script that will power on the device if itā€™s off. It also adds a POWER_ON_TIME value that is the time (in seconds) for the device to go from 0 brightness to the target brightness of each zone defined in the set_extended_color_zones() packet.

"""Paint a LIFX beam."""
import asyncio
from random import randint

from aiolifx.connection import LIFXConnection

IP_ADDR="1.2.3.4"
MAC="11:22:33:44:55:66"
TRANSITION_TIME = 10
POWER_ON_TIME = 3


@service
def paint_beam() -> None:
    """Paint colors onto beam."""

    connection = LIFXConnection(IP_ADDR, MAC)
    connection.async_setup()

    connection.device.get_power()
    connection.device.get_version()
    connection.device.get_hostfirmware()
    connection.device.get_extended_color_zones()

    while connection.device.color_zones is None:
        asyncio.sleep(0)

    new_colors = []

    for _ in range(0, len(connection.device.color_zones)):
        new_color = int(randint(0, 359) / 360 * 65535)
        new_brightness = randint(32768, 65535)
        new_colors.append((new_color, 65535, new_brightness, 3500))

    for _ in range(len(new_colors), 82):
        new_colors.append((0, 0, 0, 0))

    while connection.device.power_level is None:
        asyncio.sleep(0)

    if connection.device.power_level == 0:
        connection.device.set_power(value="on", duration=POWER_ON_TIME * 1000)

    connection.device.set_extended_color_zones(
        new_colors,
        len(connection.device.color_zones),
        duration=(TRANSITION_TIME * 1000),
    )

This appears to be a quirk of Pyscript: I canā€™t get it to work either.

I actually do need to wait for it to be true. :3

#if the light is off we need zero transition time, otherwise the light will blast us in the face

I am strangely still getting errors even with those lines uncommented. Perhaps I should wait for all messages to resolve?

@service
def z_sunrise_test2(Transition=60):
    """Window Sunshine"""

    #init
    Transition = Transition * 60 #transition time should be in minutes
    connection = LIFXConnection(IP_ADDR, MAC)
    connection.async_setup()
    connection.device.get_version()
    connection.device.get_hostfirmware()
    connection.device.get_extended_color_zones()
    connection.device.get_power()

    #await connection
    while connection.device.color_zones is None or connection.device.power_level is None:
        asyncio.sleep(0)

    power = connection.device.power_level

    #set colors for phase1 in HSBK values of 0 to 65535
    phase1 = [
        (6553, 65535, 1965, 4000), 
        (6553, 65535, 1965, 4000),
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (6553, 65535, 1965, 4000), 
        (6553, 65535, 1965, 4000)
        ]
    # Fill in the remaining empty/non-existant zones
    for _ in range(len(phase1),  82):
        phase1.append((0, 0, 0, 0))

    #if the light is off we need zero transition time, otherwise the light will blast us in the face
    if (power == 0):
        phase1_time = 0 
    else:
        phase1_time = 15
    
    #send command to light
    connection.device.set_extended_color_zones(phase1, len(connection.device.color_zones), duration=(phase1_time * 1000))
    connection.device.set_power(True,2000)

Errors:

2023-10-18 17:52:55.063 ERROR (MainThread) [homeassistant] Error doing job: Exception in callback _SelectorDatagramTransport._read_ready()
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/usr/local/lib/python3.11/asyncio/selector_events.py", line 1163, in _read_ready
    self._protocol.datagram_received(data, addr)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 223, in datagram_received
    callb(self, response)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 971, in 
    mycallb = lambda x, y: (mypartial(y), callb(x, y))
                                          ^^^^^^^^^^^
TypeError: 'int' object is not callable
2023-10-18 17:52:55.548 ERROR (MainThread) [homeassistant] Error doing job: Exception in callback _SelectorDatagramTransport._read_ready()
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/usr/local/lib/python3.11/asyncio/selector_events.py", line 1163, in _read_ready
    self._protocol.datagram_received(data, addr)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 223, in datagram_received
    callb(self, response)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 971, in 
    mycallb = lambda x, y: (mypartial(y), callb(x, y))
                                          ^^^^^^^^^^^
TypeError: 'int' object is not callable
2023-10-18 17:52:56.047 ERROR (MainThread) [homeassistant] Error doing job: Exception in callback _SelectorDatagramTransport._read_ready()
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "/usr/local/lib/python3.11/asyncio/selector_events.py", line 1163, in _read_ready
    self._protocol.datagram_received(data, addr)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 223, in datagram_received
    callb(self, response)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 971, in 
    mycallb = lambda x, y: (mypartial(y), callb(x, y))
                                          ^^^^^^^^^^^
TypeError: 'int' object is not callable
2023-10-18 17:53:08.773 ERROR (MainThread) [homeassistant] Error doing job: Task exception was never retrieved
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 347, in try_sending
    await event.wait()
  File "/usr/local/lib/python3.11/asyncio/locks.py", line 213, in wait
    await fut
asyncio.exceptions.CancelledError
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 346, in try_sending
    async with asyncio_timeout(timeout_secs):
  File "/usr/local/lib/python3.11/asyncio/timeouts.py", line 111, in __aexit__
    raise TimeoutError from exc_val
TimeoutError
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 354, in try_sending
    callb(self, None)
  File "/usr/local/lib/python3.11/site-packages/aiolifx/aiolifx.py", line 971, in 
    mycallb = lambda x, y: (mypartial(y), callb(x, y))
                                          ^^^^^^^^^^^
TypeError: 'int' object is not callable

Your connection.device.set_power() call is incomplete/incorrect. You need to specify the parameters, i.e. connection.device.set_power(value="on", duration=2000). Otherwise the Pyscript tries to invoke the parameter leading to the int object is not callable and the callb lambda issue.

If you check my updated example, Iā€™m waiting for power_level to not be None too. :slight_smile:

Oops! Good catch. In any case weā€™re up and running now. :slight_smile:. Thanks for all your help sir.

@service
def z_sunrise_1(transition=60):
    """Window Sunshine"""

    #init
    #transition = transition * 60 #putting this in seconds now handled at the input level
    connection = LIFXConnection(IP_ADDR, MAC)
    connection.async_setup()
    connection.device.get_version()
    connection.device.get_hostfirmware()
    connection.device.get_extended_color_zones()
    connection.device.get_power()

    #await connection
    while connection.device.color_zones is None or connection.device.power_level is None:
        asyncio.sleep(0)

    power = connection.device.power_level

    #set colors for phase1 in HSBK values of 0 to 65535
    phase1 = [
        (6553, 65535, 1965, 4000), 
        (6553, 65535, 1965, 4000),
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (50243, 24247, 1966, 6500), 
        (0, 0, 0, 6500), 
        (0, 0, 0, 6500), 
        (6553, 65535, 1965, 4000), 
        (6553, 65535, 1965, 4000)
        ]
    # Fill in the remaining empty/non-existant zones
    for _ in range(len(phase1),  82):
        phase1.append((0, 0, 0, 0))

    #if the light is off we need zero transition time, otherwise the light will blast us in the face
    if (power == 0):
        delay = 0 
    else:
        delay = 3
    
    #send command to light
    connection.device.set_extended_color_zones(phase1, len(connection.device.color_zones), duration=(delay * 1000))
    connection.device.set_power(True,duration=1000)

    #wait for phase 1
    phase1_time = transition * .20
    asyncio.sleep(phase1_time)

    phase2 = [
        (6400, 0, 11796, 3000),
        (6400, 0, 11796, 3000), 
        (0, 0, 0, 2000), 
        (0, 0, 0, 2000), 
        (6400, 0, 11796, 3000), 
        (0, 0, 0, 2000), 
        (6400, 0, 11796, 3000), 
        (0, 0, 0, 2000), 
        (0, 0, 0, 2000), 
        (6400, 0, 11796, 3000), 
        (0, 0, 0, 2000), 
        (6400, 0, 11796, 3000), 
        (0, 0, 0, 2000), 
        (0, 0, 0, 2000), 
        (6400, 0, 11796, 3000), 
        (6400, 0, 11796, 3000)
    ]

    # Fill in the remaining empty/non-existant zones
    for _ in range(len(phase2),  82):
        phase2.append((0, 0, 0, 0))

    phase2_time = transition * .30
    connection.device.set_extended_color_zones(phase2, len(connection.device.color_zones), duration=(phase2_time * 1000))
    asyncio.sleep(phase2_time)

    phase3_time = transition * .50
    connection.device.set_color((6400,0,65535,3000),duration=phase3_time * 1000)
    asyncio.sleep(phase3_time)

I would create a script for each phase and then trigger each one using Home Assistantā€™s timer instead of relying on this script to run for all the phases.

Or you could add parameters to this script and send the phase colors and transition time (and even target IP and mac) from Home Assistant, which gives you the flexibility of sending other stuff at other times.

Yeah I plan to split it up later.

1 Like

That is not set in stone, the YAML support could be extended to enable your use case :wink:

Yup. I hope it is changed at some point.

While Iā€™m back in this thread also it should be noted that this script needs a loop limiter of some kind because of the strip youā€™re trying to contact is disconnected you can end up in an infinite loop of trying to contact the device.