Skip to content

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 Animator, Device, MatrixLight

async def main():
    async with await Device.connect("192.168.1.100") as device:
        assert isinstance(device, MatrixLight)
        # Create animator (queries device for tile info)
        animator = await Animator.for_matrix(device)

    # 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 Animator, Device, MultiZoneLight

async def main():
    async with await Device.connect("192.168.1.100") as device:
        assert isinstance(device, MultiZoneLight)
        # Create animator
        animator = await Animator.for_multizone(device)

    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())

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

  1. The animator reads each tile's position (user_x, user_y) from the device
  2. Positions are in tile-position units, not pixels: 1.0 is always 8 pixels, whatever the tile's own size, and user_y grows upwards while canvas rows grow downwards. The animator converts them for you
  3. A canvas is created that encompasses all tiles, using each tile's own width and height
  4. Your input frame is interpreted as a 2D row-major image
  5. 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 Animator.for_matrix(device)

# 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 Animator.for_matrix(device)

# Your frame uses logical canvas coordinates
# The animator remaps to physical tile positions
animator.send_frame(logical_frame)

Supported orientations:

  • RIGHT_SIDE_UP - Normal position
  • ROTATED_90 - 90 degrees clockwise
  • ROTATED_180 - Upside down
  • ROTATED_270 - 90 degrees counter-clockwise
  • FACE_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 Animator.for_matrix(device)

# 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 Animator.for_matrix(device)

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 Capsule (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:

  1. Check device capabilities: only the original LIFX Tile has multi-tile canvas
  2. For other matrix devices (Ceiling, Candle, Path), canvas equals tile size

Wrong Colors on Rotated Tiles

Cause: Orientation not detected correctly

Solutions:

  1. Ensure device chain is loaded before creating animator
  2. Check tile accelerometer data via device.device_chain
  3. Physical tiles must be stable (not moving) for accurate orientation

Memory Growth

Cause: Creating new frame lists each iteration

Solutions:

  1. Reuse frame lists when possible
  2. Use generator patterns for very long animations
  3. Clear references after use

See Also