Animation Guide¶
This guide covers how to use the animation module for high-frequency frame delivery to LIFX devices. The animation system is designed for real-time effects at up to ~20 FPS over WiFi — a platform ceiling of the WiFi/Set64 wire path, not a client limitation.
When to Use Animation¶
Use the animation module when you need:
- High frame rates (up to ~20 FPS)
- Real-time effects from external sources
- Integration with music visualisers
- Continuous animations that run for extended periods
For simple, one-time color changes, use the device methods directly (set_color(), set_tile_colors(), etc.) instead.
Basic Usage¶
Matrix Devices (Tiles, Candle, Path)¶
import asyncio
from lifx import Device, MatrixLight
async def main():
async with await Device.connect("192.168.1.100") as device:
assert isinstance(device, MatrixLight)
# Borrow the device's own Animator (queries tile info once)
animator = await device.animator.prepare()
# Device connection closed - animator sends via direct UDP
print(f"Canvas: {animator.canvas_width}x{animator.canvas_height}")
print(f"Total pixels: {animator.pixel_count}")
try:
# Animation loop
for _ in range(100):
# Generate frame (H, S, B, K as uint16)
frame = [(65535, 65535, 65535, 3500)] * animator.pixel_count
# send_frame() is synchronous for speed
stats = animator.send_frame(frame)
print(f"Sent {stats.packets_sent} packets")
await asyncio.sleep(1 / 20) # 20 FPS
finally:
animator.close()
asyncio.run(main())
MultiZone Devices (Strips, Beams)¶
import asyncio
from lifx import Device, MultiZoneLight
async def main():
async with await Device.connect("192.168.1.100") as device:
assert isinstance(device, MultiZoneLight)
# Borrow the device's own Animator (queries zone count once)
animator = await device.animator.prepare()
print(f"Device has {animator.pixel_count} zones")
try:
# Animation loop
for _ in range(100):
# Generate frame
frame = [(0, 65535, 65535, 3500)] * animator.pixel_count
stats = animator.send_frame(frame)
await asyncio.sleep(1 / 20)
finally:
animator.close()
asyncio.run(main())
One Animator per Light¶
Every light owns exactly one Animator, device.animator, created on first access. Library
effects run by the Conductor borrow it too, so your frames and an effect's frames on the same
light go through one writer and one ack gate, and traffic to the light stays capped.
- A single light's Animator is ready at once.
- A matrix or multizone light's Animator must be prepared before its first frame:
await device.animator.prepare()queries the tile info or zone count once and returns the Animator. Later calls make no query. - Set
animator.duration_msfor firmware interpolation between your frames (default 0). animator.close()closes the UDP socket. The next frame reopens it, so closing is safe even while an effect shares the Animator.
Animator.for_matrix(), Animator.for_multizone() and Animator.for_light() still work but are
deprecated: they emit a DeprecationWarning and return the device's own Animator, so two calls
return the same object. for_light() cannot prepare a matrix or multizone light; use
device.animator instead.
Lights on Thread¶
A Thread mesh is not built for a steady stream of frames. prepare() refuses a light evidenced
as Thread with LifxUnsupportedCommandError, unless you pass enable_thread=True:
The evidence is the same as for get_wifi_info() and get_thread_info(): the light's own
replies, or an mDNS record that says Thread. A light the library has not heard from yet is not
refused. With enable_thread=True the frames go out without a warning. The deprecated
for_matrix(), for_multizone() and for_light() factories take the same keyword.
Multi-Tile Canvas¶
For devices with multiple tiles (like the original 5-tile LIFX Tile), the animator automatically creates a unified canvas based on tile positions. This allows animations to span across all tiles as one continuous image, rather than each tile showing a mirrored copy.
How It Works¶
- The animator reads each tile's position (
user_x,user_y) from the device - Positions are in tile-position units, not pixels: 1.0 is always 8 pixels, whatever the
tile's own size, and
user_ygrows upwards while canvas rows grow downwards. The animator converts them for you - A canvas is created that encompasses all tiles, using each tile's own width and height
- Your input frame is interpreted as a 2D row-major image
- Each tile extracts its region from the canvas based on its position
Example: 5 Horizontal Tiles¶
async with await Device.connect("192.168.1.100") as device:
assert isinstance(device, MatrixLight)
animator = await device.animator.prepare()
# For 5 tiles arranged horizontally:
# - canvas_width = 40 (5 tiles x 8 pixels)
# - canvas_height = 8
# - pixel_count = 320 (40 x 8)
print(f"Canvas: {animator.canvas_width}x{animator.canvas_height}")
# Generate a gradient that flows across ALL tiles
frame = []
for y in range(animator.canvas_height):
for x in range(animator.canvas_width):
# Hue varies from 0 to 65535 across the full width
hue = int(x / animator.canvas_width * 65535)
frame.append((hue, 65535, 65535, 3500))
animator.send_frame(frame) # Rainbow spans all 5 tiles!
Canvas Coordinate System¶
The canvas uses row-major ordering:
For a 40x8 canvas (5 horizontal tiles):
Index: 0 1 2 3 4 ... 39 (row 0)
40 41 42 43 44 ... 79 (row 1)
...
280 281 ... 319 (row 7)
Tile positions:
Tile 0: x=0-7, y=0-7
Tile 1: x=8-15, y=0-7
Tile 2: x=16-23, y=0-7
Tile 3: x=24-31, y=0-7
Tile 4: x=32-39, y=0-7
Understanding HSBK Format¶
The animation module uses protocol-ready HSBK values for performance:
# HSBK tuple: (hue, saturation, brightness, kelvin)
# - Hue: 0-65535 (maps to 0-360 degrees)
# - Saturation: 0-65535 (maps to 0.0-1.0)
# - Brightness: 0-65535 (maps to 0.0-1.0)
# - Kelvin: 1500-9000
# Examples
red = (0, 65535, 65535, 3500) # Full red
blue = (43690, 65535, 65535, 3500) # Full blue (240/360 * 65535)
white = (0, 0, 65535, 5500) # Daylight white
dim_warm = (0, 0, 16384, 2700) # 25% warm white
off = (0, 0, 0, 3500) # Off (black)
Converting from User-Friendly Values¶
def to_protocol_hsbk(
hue: float, # 0-360 degrees
saturation: float, # 0.0-1.0
brightness: float, # 0.0-1.0
kelvin: int, # 1500-9000
) -> tuple[int, int, int, int]:
"""Convert user-friendly values to protocol format."""
return (
int(hue / 360 * 65535),
int(saturation * 65535),
int(brightness * 65535),
kelvin,
)
# Usage
red = to_protocol_hsbk(0, 1.0, 1.0, 3500)
blue = to_protocol_hsbk(240, 1.0, 1.0, 3500)
Converting from RGB¶
def rgb_to_protocol_hsbk(
r: int, g: int, b: int, # 0-255
kelvin: int = 3500,
) -> tuple[int, int, int, int]:
"""Convert RGB to protocol HSBK."""
# Normalise to 0-1
r_norm = r / 255
g_norm = g / 255
b_norm = b / 255
max_c = max(r_norm, g_norm, b_norm)
min_c = min(r_norm, g_norm, b_norm)
delta = max_c - min_c
# Brightness
brightness = max_c
# Saturation
if max_c == 0:
saturation = 0
else:
saturation = delta / max_c
# Hue
if delta == 0:
hue = 0
elif max_c == r_norm:
hue = 60 * (((g_norm - b_norm) / delta) % 6)
elif max_c == g_norm:
hue = 60 * (((b_norm - r_norm) / delta) + 2)
else:
hue = 60 * (((r_norm - g_norm) / delta) + 4)
return (
int(hue / 360 * 65535),
int(saturation * 65535),
int(brightness * 65535),
kelvin,
)
Tile Orientation Handling¶
For matrix devices with the has_chain capability (like the original LIFX Tile), tiles may be
physically rotated. The animator automatically handles orientation correction:
async with await Device.connect("192.168.1.100") as device:
assert isinstance(device, MatrixLight)
# Orientation is detected from device accelerometer data
animator = await device.animator.prepare()
# Your frame uses logical canvas coordinates
# The animator remaps to physical tile positions
animator.send_frame(logical_frame)
Supported orientations:
RIGHT_SIDE_UP- Normal positionROTATED_90- 90 degrees clockwiseROTATED_180- Upside downROTATED_270- 90 degrees counter-clockwiseFACE_UP- Facing ceiling (treated as right-side-up for 2D mapping)FACE_DOWN- Facing floor (treated as right-side-up for 2D mapping)
Performance Tips¶
The Animation Loop Pattern¶
async with await Device.connect("192.168.1.100") as device:
assert isinstance(device, MatrixLight)
animator = await device.animator.prepare()
# Device connection closed here - animator works via direct UDP
try:
while running:
frame = generate_frame()
animator.send_frame(frame) # Synchronous, very fast
await asyncio.sleep(1 / target_fps)
finally:
animator.close() # Clean up UDP socket
Pre-generate Frames¶
# Generate frames in advance
frames = []
for i in range(100):
frame = generate_animation_frame(i)
frames.append(frame)
# Play back at consistent rate
for frame in frames:
animator.send_frame(frame)
await asyncio.sleep(1 / 20)
Use NumPy for Large Canvases¶
For large devices or complex animations, NumPy can speed up frame generation:
import numpy as np
def generate_gradient_numpy(width: int, height: int, hue_offset: int) -> list:
"""Generate rainbow gradient using NumPy."""
# Create coordinate grids
x = np.arange(width)
y = np.arange(height)
xx, yy = np.meshgrid(x, y)
# Calculate hues based on position
hues = ((xx + yy * 0.5 + hue_offset) * 1000) % 65536
# Build frame array
frame = np.zeros((height, width, 4), dtype=np.uint16)
frame[:, :, 0] = hues # Hue
frame[:, :, 1] = 65535 # Saturation
frame[:, :, 2] = 65535 # Brightness
frame[:, :, 3] = 3500 # Kelvin
# Convert to list of tuples (row-major)
return [tuple(p) for p in frame.reshape(-1, 4)]
For a complete example including vectorised RGB to HSBK conversion, see examples/animation_numpy.py.
Monitor Statistics¶
total_packets = 0
frame_count = 0
start_time = time.monotonic()
for frame in animation:
stats = animator.send_frame(frame)
total_packets += stats.packets_sent
frame_count += 1
elapsed = time.monotonic() - start_time
fps = frame_count / elapsed
print(f"Average FPS: {fps:.1f}")
print(f"Total packets: {total_packets}")
print(f"Avg packets/frame: {total_packets / frame_count:.1f}")
Streaming and Flow Control¶
The animation layer paces frame delivery against device acknowledgements
internally. When a device falls behind, new frames are dropped, never queued —
latest-frame-wins. There is no consumer-facing configuration: you call the same
Animator API and the library decides the delivery strategy.
What Not to Reimplement¶
If you are building a streaming consumer (a music visualiser, a LedFx-style integration, or any continuous frame source), the library already owns delivery pacing. Do not add:
- Your own acknowledgement tracking — delivery is already paced against device acknowledgements.
- Keepalive daemons — a continuous stream keeps the device awake by itself.
- Frame-retry wrappers — retrying a dropped frame is actively wrong. Latest-frame-wins means the correct recovery is simply the next frame.
Minimal Streaming Loop¶
Your whole job is to produce frames and send them at your chosen FPS:
async with await Device.connect("192.168.1.100") as device:
assert isinstance(device, MatrixLight)
animator = await device.animator.prepare()
target_fps = 20 # platform ceiling over WiFi; large matrix devices sustain less
try:
while running:
frame = generate_frame() # your only job: produce frames
animator.send_frame(frame) # the library paces delivery
await asyncio.sleep(1 / target_fps)
finally:
animator.close()
Choosing an FPS per Device Class¶
~20 FPS is the platform ceiling over WiFi/Set64. Larger multi-packet matrix devices saturate sooner: the LIFX Ceiling 13x26 (16×8 zones — 128 zones — at 3 packets per frame) sustains ~10 FPS.
Oversending is safe but visible: when the frame rate exceeds what the device sustains, latest-frame-wins drops frames and the animation stutters. This is degradation by design — never a backlog or freeze. If you see stutter, reduce your FPS toward the device's sustainable rate.
To observe gating, check the AnimatorStats returned by send_frame(): stats.gated
reports whether that frame was dropped, and stats.acks_outstanding shows how far the
device is behind.
Gen4 devices add a small wake-up latency to the first packet after idle — if a stream starts sluggishly, see Gen4 Power-Save Wake Tail.
Troubleshooting¶
Flickering or Glitches¶
Primary cause (by design): Device saturation — the frame rate exceeds what the device sustains, so latest-frame-wins drops frames and the animation stutters. This is never a backlog or freeze.
Solution: Reduce FPS toward the device's sustainable rate (see the Streaming and Flow
Control section above). stats.gated from send_frame() shows when frames are being dropped.
Secondary cause (genuine): Network loss — weak WiFi signal to the device.
Solution: Improve the signal or use a stronger access point.
Animation Appears on Each Tile Separately¶
Cause: Device doesn't have has_chain capability, so canvas mode isn't used
Solutions:
- Check device capabilities: only the original LIFX Tile has multi-tile canvas
- For other matrix devices (Ceiling, Candle, Path), canvas equals tile size
Wrong Colors on Rotated Tiles¶
Cause: Orientation not detected correctly
Solutions:
- Ensure device chain is loaded before creating animator
- Check tile accelerometer data via
device.device_chain - Physical tiles must be stable (not moving) for accurate orientation
Memory Growth¶
Cause: Creating new frame lists each iteration
Solutions:
- Reuse frame lists when possible
- Use generator patterns for very long animations
- Clear references after use
See Also¶
- Animation API Reference — Class reference, packet generators, and performance characteristics