Skip to content

Effects Architecture

This document provides a comprehensive overview of the Light Effects Framework architecture, including design decisions, implementation details, and lifecycle management.

Table of Contents

High-Level Overview

The Light Effects Framework is built on a layered architecture that separates concerns and provides a clean abstraction for effect management:

┌─────────────────────────────────────────────────────┐
│              Application Layer                       │
│  (User code, business logic, effect selection)      │
└────────────────┬────────────────────────────────────┘
┌────────────────▼────────────────────────────────────┐
│              Effects API Layer                       │
│   • Conductor (orchestration + dynamic lights)      │
│   • EffectPulse (firmware waveforms via Device)     │
│   • FrameEffect → Colorloop, Rainbow, Flame,       │
│     Aurora, Progress, Sunrise/Sunset (frame-based)  │
│   • EffectRegistry (discovery + filtering)          │
│   • LIFXEffect (base class)                         │
└──────┬─────────────────────────────┬────────────────┘
       │ (EffectPulse)               │ (FrameEffect)
┌──────▼──────────────────┐  ┌───────▼───────────────────┐
│  Device Layer            │  │  Animation Layer           │
│  (lifx.devices)          │  │  (lifx.animation)          │
│  • set_waveform()        │  │  • Animator (direct UDP)   │
│  • set_color()           │  │  • PacketGenerator         │
└──────┬──────────────────┘  │  • FrameBuffer              │
       │                      └───────┬───────────────────┘
       │                              │
┌──────▼──────────────────────────────▼───────────────┐
│           Network / Protocol Layers                  │
│   • UDP transport, binary serialization             │
└─────────────────────────────────────────────────────┘

Key Principles

  1. Zero Dependencies: Uses only Python stdlib and existing lifx-async components
  2. State Preservation: Automatically captures and restores device state
  3. Type Safety: Full type hints with strict Pyright validation
  4. Async/Await: Native asyncio for concurrent operations
  5. Extensibility: Abstract base class for custom effects

Component Architecture

Module Structure

src/lifx/effects/
├── __init__.py              # Public API exports
├── base.py                  # LIFXEffect abstract base class
├── frame_effect.py          # FrameEffect base class + FrameContext
├── conductor.py             # Conductor orchestrator
├── pulse.py                 # EffectPulse implementation
├── colorloop.py             # EffectColorloop (FrameEffect) implementation
├── rainbow.py               # EffectRainbow (FrameEffect) implementation
├── flame.py                 # EffectFlame (FrameEffect) implementation
├── aurora.py                # EffectAurora (FrameEffect) implementation
├── progress.py              # EffectProgress (FrameEffect, multizone only)
├── sunrise.py               # EffectSunrise + EffectSunset (FrameEffect, matrix only)
├── registry.py              # EffectRegistry, DeviceType, DeviceSupport, EffectInfo
├── models.py                # PreState, RunningEffect dataclasses
├── state_manager.py         # DeviceStateManager for state capture/restore
└── const.py                 # Effect constants

Component Responsibilities

Conductor (conductor.py)

Purpose: Central orchestrator managing effect lifecycle across multiple devices.

Responsibilities:

  • Track running effects per device (serial → RunningEffect mapping)
  • Capture device state before effects
  • Power on devices if needed
  • Execute effects via effect.async_perform()
  • Restore device state after effects complete
  • Handle concurrent effects on different devices
  • Provide thread-safe state management with asyncio.Lock()

Key Data Structures:

class Conductor:
    _running: dict[str, RunningEffect]  # serial → RunningEffect
    _lock: asyncio.Lock                 # Thread-safe access

LIFXEffect Base Class (base.py)

Purpose: Abstract base for all effect implementations.

Responsibilities:

  • Define effect interface (abstract async_play() method)
  • Handle power-on logic in async_perform()
  • Provide startup color via from_poweroff_hsbk()
  • Enable state inheritance optimization via inherit_prestate()
  • Store conductor reference and participants

Key Methods:

class LIFXEffect(ABC):
    async def async_perform(participants):  # Setup + call async_play()
        ...

    @abstractmethod
    async def async_play():                 # Effect logic (override)
        ...

    async def from_poweroff_hsbk(light):    # Startup color (override)
        ...

    def inherit_prestate(other):            # State inheritance (override)
        ...

FrameEffect Base Class (frame_effect.py)

Purpose: Abstract base for effects that generate color frames at a fixed FPS.

Responsibilities:

  • Define frame generation interface (abstract generate_frame() method)
  • Run frame loop with timing control (fps, duration, stop_event)
  • Create FrameContext per device per frame with timing and layout info
  • Convert user-friendly HSBK colors to protocol tuples
  • Dispatch frames via Animators (set by Conductor)
  • Provide async_setup() hook for pre-loop initialization

Key Data:

class FrameEffect(LIFXEffect):
    _fps: float                    # Frames per second
    _duration: float | None        # Run time limit (None = infinite)
    _stop_event: asyncio.Event     # Stop signal
    _animators: list[Animator]     # Set by Conductor before play

Relationship to LIFXEffect: FrameEffect extends LIFXEffect and implements async_play() as a frame loop. Subclasses implement generate_frame() instead.

Effect Implementations

EffectPulse (pulse.py):

  • Extends LIFXEffect directly
  • Implements pulse/blink/breathe effects via firmware waveforms
  • Five modes with different timing and waveforms
  • Intelligent color selection based on mode
  • Auto-completion after configured cycles

EffectColorloop (colorloop.py):

  • Extends FrameEffect (not LIFXEffect directly)
  • Implements continuous hue rotation via frame generation
  • Time-based hue calculation with configurable FPS
  • Randomized direction and saturation
  • Runs indefinitely until stopped
  • Supports state inheritance for seamless transitions
  • Works across all device types (Light, MultiZoneLight, MatrixLight)

EffectRainbow (rainbow.py):

  • Extends FrameEffect
  • Spreads full 360-degree rainbow across all pixels, scrolling over time
  • Configurable period, brightness, saturation, and inter-device spread
  • Best on multizone strips and matrix lights

EffectFlame (flame.py):

  • Extends FrameEffect
  • Layered sine waves produce organic flicker (no random state)
  • Warm color range: hue 0-40, high saturation, configurable kelvin range
  • Matrix enhancement: vertical brightness falloff (bottom rows hotter)
  • Works on all color device types

EffectAurora (aurora.py):

  • Extends FrameEffect
  • Palette interpolation with shortest-path hue wrapping
  • Sine wave brightness modulation creates flowing "curtain" bands
  • Matrix enhancement: vertical gradient (brightest in middle rows)
  • Configurable palette, speed, brightness, and inter-device spread

EffectProgress (progress.py):

  • Extends FrameEffect
  • Animated progress bar with traveling bright spot
  • Mutable position attribute for real-time updates
  • Supports single color or gradient foreground
  • Multizone only (has_multizone required)

EffectSunrise + EffectSunset (sunrise.py):

  • Both extend FrameEffect
  • Duration-based (finite) — complete automatically
  • Shared _sun_frame() helper: radial model centered at configurable origin
  • Five color phases: night → dawn → golden hour → morning → daylight
  • origin parameter: "bottom" (rectangular tiles) or "center" (Ceiling lights)
  • Sunrise: restore_on_complete=False (stays at daylight)
  • Sunset: optionally powers off lights after last frame
  • Matrix only (has_matrix required)

EffectRegistry (registry.py):

  • Central discovery mechanism for effect compatibility
  • DeviceType enum: LIGHT, MULTIZONE, MATRIX
  • DeviceSupport enum: RECOMMENDED, COMPATIBLE, NOT_SUPPORTED
  • EffectInfo frozen dataclass with name, class, description, support map
  • get_effect_registry() returns lazily-initialized default registry with all built-in effects

Data Models (models.py)

PreState:

Stores device state before effect:

@dataclass
class PreState:
    power: bool                     # Power state (on/off)
    color: HSBK                     # Current color
    zone_colors: list[HSBK] | None  # Multizone colors (if applicable)

RunningEffect:

Associates effect with its pre-state:

@dataclass
class RunningEffect:
    effect: LIFXEffect    # Effect instance
    prestate: PreState    # Captured state

Effect Lifecycle

The effect lifecycle consists of five distinct phases:

1. Initialization

User creates effect instance with desired parameters:

effect = EffectPulse(mode='blink', cycles=5)

What happens:

  • Effect object created with parameters stored
  • No network activity yet
  • No conductor association yet

2. State Capture

Conductor starts effect and captures current device state:

await conductor.start(effect, [light1, light2])

What happens:

For each light:
  1. Check if prestate can be inherited from running effect
  2. If not, capture new prestate:
     a. Get power state (get_power)
     b. Get current color (get_color)
     c. Get zone colors if multizone (get_color_zones or get_extended_color_zones)
  3. Store in RunningEffect and register in conductor._running

Timing: <1 second per device (mostly network I/O)

Special Cases:

  • Prestate Inheritance: If effect.inherit_prestate(current_effect) returns True, reuses existing PreState
  • Multizone Devices: Uses extended messages if supported, falls back to standard messages
  • Powered-off Devices: All state is still captured (including zone colors that may be inaccurate)

3. Animator Creation (FrameEffect Only)

For FrameEffect subclasses, the Conductor creates Animators before the frame loop:

if isinstance(effect, FrameEffect):
    animators = await self._create_animators(effect, participants)
    effect._animators = animators
    await effect.async_setup(participants)

What happens:

For each participant:
  1. Detect device type (MatrixLight, MultiZoneLight, Light)
  2. Create appropriate Animator factory:
     - MatrixLight → Animator.for_matrix(device, duration_ms)
     - MultiZoneLight → Animator.for_multizone(device, duration_ms)
     - Light → Animator.for_light(device, duration_ms)
  3. duration_ms = int(1000 / effect.fps) for smooth interpolation
  4. Call effect.async_setup(participants) for pre-loop initialization

Note: Animators use direct UDP — no device connection needed after creation.

4. Power-On (Optional)

If effect.power_on == True, devices are powered on:

async def async_perform(self, participants):
    if self.power_on:
        for light in self.participants:
            power_level = await light.get_power()
            if power_level == 0:
                startup_color = await self.from_poweroff_hsbk(light)
                await light.set_color(startup_color, duration=0)
                await light.set_power(True, duration=0.3)

What happens:

For each powered-off light:
  1. Get startup color from from_poweroff_hsbk()
  2. Set color immediately (duration=0)
  3. Power on with 0.3s fade (duration=0.3)

Timing: 0.3 seconds per powered-off device

5. Effect Execution

Effect logic runs via async_play():

await effect.async_play()

What happens:

  • LIFXEffect subclasses: Custom effect logic using device methods (e.g., set_waveform())
  • FrameEffect subclasses: Frame loop calling generate_frame() per device per frame, dispatching via Animators

Timing:

  • EffectPulse: period * cycles seconds
  • EffectColorloop: Runs indefinitely at calculated FPS

6. State Restoration & Cleanup

Conductor restores devices to pre-effect state:

await conductor.stop([light1, light2])

What happens:

For each light:
  1. Restore multizone colors (if applicable):
     - Use extended messages if supported
     - Use standard messages with apply=NO_APPLY, then apply=APPLY
     - Wait 0.3s for device processing
  2. Restore color:
     - set_color(prestate.color, duration=0)
     - Wait 0.3s
  3. Restore power:
     - set_power(prestate.power, duration=0)

Timing: 0.6-1.0 seconds per device (includes delays)

Special Cases:

  • Multizone devices get zones restored first
  • 0.3s delays ensure device processing completes
  • Errors are logged but don't stop other devices

State Management

State Storage

The conductor maintains a registry of running effects:

_running: dict[str, RunningEffect]

Key: Device serial number (12-digit hex string) Value: RunningEffect containing effect instance and captured PreState

State Capture Details

Power State

Integer power level captured via get_power():

power_level = await light.get_power()  # Returns int (0 or 65535)
is_on = power_level > 0

Color State

HSBK color captured via get_color():

color, power, _ = await light.get_color()  # power is int (0 or 65535)

Returns:

  • color: HSBK (hue, saturation, brightness, kelvin)
  • power: Power level as integer (0 for off, 65535 for on)
  • label: Device label

Multizone State

For MultiZoneLight devices, zone colors are captured:

if isinstance(light, MultiZoneLight):
    # Get all zones using the convenience method
    # Automatically uses the best method based on capabilities
    zone_colors = await light.get_all_color_zones()

Extended Multizone:

  • Single message retrieves all zones
  • Returns list[HSBK] with all zone colors
  • More efficient, used when available

Standard Multizone:

  • Retrieves zones in batches of 8
  • Multiple messages required for >8 zones
  • Used as fallback for older devices

State Restoration Details

Multizone Restoration

Zones are restored before color and power:

Extended Multizone:

await light.set_extended_color_zones(
    zone_index=0,
    colors=zone_colors,
    duration=0.0,
    apply=MultiZoneExtendedApplicationRequest.APPLY
)

Single message restores all zones.

Standard Multizone:

for i, color in enumerate(zone_colors):
    is_last = (i == len(zone_colors) - 1)
    apply = APPLY if is_last else NO_APPLY

    await light.set_color_zones(
        start=i, end=i,
        color=color,
        duration=0.0,
        apply=apply
    )

Multiple messages with apply logic:

  • NO_APPLY (0): Update buffer, don't display
  • APPLY (1): Update buffer and display (used on last zone)

This ensures atomic update visible only when all zones are set.

Timing Delays

Critical 0.3-second delays ensure device processing:

# After multizone restoration
await asyncio.sleep(0.3)

# After color restoration
await asyncio.sleep(0.3)

# No delay after power (last operation)

Without these delays, subsequent operations may arrive before device finishes processing, causing state corruption.

Prestate Inheritance

Optimization that skips state capture/restore for compatible consecutive effects:

def inherit_prestate(self, other: LIFXEffect) -> bool:
    """Return True if can skip restoration."""
    return isinstance(other, EffectColorloop)  # Example

When used:

current_running = self._running.get(serial)
if current_running and effect.inherit_prestate(current_running.effect):
    # Reuse existing prestate
    prestate = current_running.prestate
else:
    # Capture new prestate
    prestate = await self._capture_prestate(light)

Benefits:

  • Eliminates flash/reset between compatible effects
  • Reduces network traffic
  • Faster effect transitions

Used by:

  • EffectColorloop.inherit_prestate()True for other EffectColorloop
  • EffectRainbow.inherit_prestate()True for other EffectRainbow
  • EffectFlame.inherit_prestate()True for other EffectFlame
  • EffectAurora.inherit_prestate()True for other EffectAurora
  • EffectProgress.inherit_prestate()True for other EffectProgress
  • EffectSunrise.inherit_prestate()True for other EffectSunrise
  • EffectSunset.inherit_prestate()True for other EffectSunset
  • EffectPulse doesn't use it (returns False)

Concurrency Model

Thread Safety

The conductor uses an asyncio.Lock() for thread-safe state management:

async def start(self, effect, participants):
    async with self._lock:
        # Critical section: state capture and registration
        for light in participants:
            prestate = await self._capture_prestate(light)
            self._running[light.serial] = RunningEffect(effect, prestate)

    # Effect execution happens outside lock (concurrent)
    await effect.async_perform(participants)

Why lock is needed:

  • Prevents race conditions when starting/stopping effects concurrently
  • Protects _running dictionary modifications
  • Ensures atomic state capture and registration

Why effect execution is outside lock:

  • Allows multiple effects to run concurrently on different devices
  • Effect logic doesn't modify conductor state
  • Prevents blocking other operations during long-running effects

Concurrent Device Operations

Effects use asyncio.gather() for concurrent device operations:

# Apply waveform to all devices concurrently
tasks = [
    light.set_waveform(color, period, cycles, waveform)
    for light in self.participants
]
await asyncio.gather(*tasks)

Benefits:

  • Multiple devices updated in parallel
  • Network latency overlaps
  • Total time ≈ single device time (not N × device time)

Background Response Dispatcher

Each DeviceConnection has a background receiver task that routes responses:

# In DeviceConnection
async def _response_receiver(self):
    while self._running:
        packet = await self._receive_packet()
        # Route by sequence number to waiting coroutine
        self._pending[seq_num].set_result(packet)

Implications for effects:

  • Multiple concurrent requests on same device are supported
  • Responses are correctly matched even with concurrent operations
  • No additional coordination needed in effect code

Effect Concurrency Patterns

Pattern 1: Sequential Effects on Same Devices

# Effect 1 completes before Effect 2 starts
await conductor.start(effect1, lights)
await asyncio.sleep(duration1)
await conductor.start(effect2, lights)  # Captures new state

State is automatically restored between effects.

Pattern 2: Concurrent Effects on Different Devices

# Different devices, completely independent
await conductor.start(effect1, group1)
await conductor.start(effect2, group2)
# Both run concurrently

No locking needed - different devices, different state.

Pattern 3: Replacing Running Effect

# Start effect1
await conductor.start(effect1, lights)
await asyncio.sleep(5)

# Replace with effect2
await conductor.start(effect2, lights)  # Prestate may be inherited

If effect2.inherit_prestate(effect1) returns True, no restoration happens.

Device Type Handling

Device Capabilities Detection

The effects framework adapts to device capabilities automatically:

# Check if multizone
if isinstance(light, MultiZoneLight):
    # Capture zone colors
    zone_colors = await light.get_color_zones(...)

# Check if extended multizone supported
if light.capabilities and light.capabilities.has_extended_multizone:
    # Use efficient extended messages
    await light.set_extended_color_zones(...)

Device-Specific Behavior

Color Lights (Light)

  • Full HSBK color support
  • All effect parameters apply
  • No special handling needed

Multizone Lights (MultiZoneLight)

  • State Capture: Zone colors captured using extended or standard messages
  • State Restoration: All zones restored with proper apply logic
  • Effect Behavior: Entire device pulses/cycles together (zones not individually controlled)
  • Timing: 0.3s delay after zone restoration

Matrix Lights (MatrixLight)

  • FrameEffect support: Full 2D canvas via FrameContext.canvas_width / canvas_height
  • Spatial effects: Flame (vertical gradient), Aurora (vertical brightness), Sunrise/Sunset (radial wavefront)
  • Canvas mapping: Multi-tile devices get a unified canvas based on tile positions

HEV Lights (HevLight)

  • Treated like standard color lights
  • HEV cycle not affected by effects
  • Effects don't interfere with HEV functionality

Infrared Lights (InfraredLight)

  • Treated like standard color lights
  • Infrared LED not affected by color effects
  • Effects only control visible light

Monochrome/White Lights

  • Color parameters ignored: Hue and saturation have no effect
  • Brightness works: Effects can still toggle/fade brightness
  • Kelvin preserved: Temperature setting maintained
  • Recommendation: Limited usefulness (only brightness changes visible)

Design Decisions

Why Conductor Pattern?

Decision: Central conductor manages all effect lifecycle instead of effects managing themselves.

Rationale:

  1. Centralized State: Single source of truth for what's running where
  2. Consistent State Management: All effects get same capture/restore logic
  3. Concurrency Control: Single lock protects all state modifications
  4. User Simplicity: Users don't manage state manually

Alternative Considered: Effects self-manage state

Rejected because: Would require each effect to duplicate state logic, higher chance of bugs

Why Abstract Base Class?

Decision: LIFXEffect is abstract with required async_play() override.

Rationale:

  1. Type Safety: Enforces effect interface at type-check time
  2. Code Reuse: Common setup logic in async_perform()
  3. Extensibility: Users can create custom effects easily
  4. Consistency: All effects follow same pattern

Why Two-Phase Effect Execution?

Decision: async_perform() calls async_play() instead of single method.

Rationale:

  1. Separation of Concerns: Setup logic separate from effect logic
  2. User Simplicity: Users only override async_play(), setup is automatic
  3. Consistency: All effects get same power-on behavior
  4. Flexibility: Base class can add more setup steps without breaking subclasses

Why No Rate Limiting?

Decision: Effects don't implement rate limiting.

Rationale:

  1. Simplicity: Keeps core library simple and focused
  2. Flexibility: Applications have different rate limit needs
  3. Transparency: Users see actual device behavior
  4. Consistency: Matches lifx-async philosophy (no hidden rate limiting)

Recommendation: Applications should implement rate limiting if sending many concurrent requests.

Why 0.3-Second Delays?

Decision: Fixed 0.3-second delays between state restoration operations.

Rationale:

  1. Device Processing Time: LIFX devices need time to process commands
  2. Empirical Testing: 0.3s works reliably across all device types
  3. State Integrity: Prevents race conditions and state corruption
  4. Trade-off: Slightly slower restoration but guaranteed correctness

Alternative Considered: No delays, faster restoration

Rejected because: Causes state corruption and unpredictable behavior

Why Prestate Inheritance?

Decision: Optional optimization via inherit_prestate() method.

Rationale:

  1. Performance: Eliminates unnecessary state reset
  2. User Experience: No visible flash between compatible effects
  3. Opt-in: Only used when effect explicitly enables it
  4. Safe Default: Returns False unless overridden

Use Cases:

  • ColorLoop → ColorLoop: Seamless transition
  • Pulse → Pulse: Could enable but currently disabled
  • Different types: Should not inherit (different visual intent)

Why FrameEffect Pattern?

Decision: Separate frame generation from frame delivery via FrameEffect + animation module.

Rationale:

  1. Device Agnostic: Effects work across all device types (Light, MultiZoneLight, MatrixLight) without device-specific code
  2. Clean Separation: Effect authors implement generate_frame() returning HSBK colors; animation module handles packet construction, tile mapping, and UDP delivery
  3. Spatial Awareness: FrameContext provides pixel_count, canvas_width, canvas_height — enabling 2D effects (fire, rain) on matrix devices
  4. Performance: Direct UDP via prebaked packet templates, no connection overhead
  5. Smooth Transitions: duration_ms parameter tells firmware to interpolate between frames

Alternative Considered: Keep all effects using device methods (set_color(), set_color_zones())

Rejected because: Required device-specific branching in every effect, couldn't leverage the animation module's performance, and made spatial effects impractical.

Integration Points

With Device Layer

LIFXEffect subclasses (e.g., EffectPulse) use standard device methods:

  • get_power(), set_power() (state capture/restore, power-on)
  • get_color(), set_color() (state capture/restore)
  • set_waveform() (EffectPulse firmware waveforms)
  • get_color_zones(), set_color_zones() (MultiZoneLight state)
  • get_extended_color_zones(), set_extended_color_zones() (MultiZoneLight state)

With Animation Layer

FrameEffect subclasses (e.g., EffectColorloop) use the animation module for frame delivery:

  • Animator.for_light() — single-light SetColor packets
  • Animator.for_multizone() — multi-zone SetExtendedColorZones packets
  • Animator.for_matrix() — multi-tile Set64 packets with canvas mapping
  • animator.send_frame() — synchronous frame dispatch (no async overhead)
  • duration_ms parameter — firmware-level interpolation between frames

The Conductor creates and manages Animator lifecycle (creation in start(), cleanup in stop()).

With Network Layer

State capture/restore relies on existing lazy connection and concurrent request support:

  • Lazy connections open on first request and are reused
  • Requests are serialized via lock to prevent response mixing
  • FrameEffect frame delivery bypasses connections (direct UDP via Animator)

With Protocol Layer

Effects use existing protocol structures:

  • HSBK for color representation + HSBK.as_tuple() for protocol conversion
  • LightWaveform enum for waveform types (EffectPulse)
  • MultiZoneApplicationRequest for zone apply logic (state restore)
  • Auto-generated packet classes

Performance Characteristics

Memory Usage

  • Conductor: ~10KB base + running effects
  • Per Effect: ~1KB per device + effect-specific state
  • PreState: ~200 bytes per device (~100 bytes + zone colors)

CPU Usage

  • Minimal: Async I/O bound, not CPU bound
  • Concurrency: Multiple devices don't increase CPU significantly
  • Background Tasks: Requests serialized per connection, concurrent across devices

Network Traffic

State Capture

  • Power: 1 request per device
  • Color: 1 request per device
  • Multizone: 1 request (extended) or N/8 requests (standard)
  • Total: 3-4 packets per device

Effect Execution

  • Pulse: 1 waveform packet per device (via device connection)
  • FrameEffect (e.g., ColorLoop): 1 frame per device per FPS tick (via direct UDP Animator)

State Restoration

  • Multizone: 1 request (extended) or N requests (standard)
  • Color: 1 request per device
  • Power: 1 request per device
  • Total: 2-3 packets per device (or N+2 for standard multizone)

Scalability

  • Tested: 10+ devices
  • Expected: 50+ devices in production
  • Limitation: Network capacity and device response time
  • Recommendation: For 50+ devices, consider grouping effects or staggering start times

See Also