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
- Component Architecture
- Effect Lifecycle
- State Management
- Concurrency Model
- Device Type Handling
- Design Decisions
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¶
- Zero Dependencies: Uses only Python stdlib and existing lifx-async components
- State Preservation: Automatically captures and restores device state
- Type Safety: Full type hints with strict Pyright validation
- Async/Await: Native asyncio for concurrent operations
- 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
FrameContextper 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
LIFXEffectdirectly - 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
positionattribute for real-time updates - Supports single color or gradient foreground
- Multizone only (
has_multizonerequired)
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
originparameter:"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_matrixrequired)
EffectRegistry (registry.py):
- Central discovery mechanism for effect compatibility
DeviceTypeenum: LIGHT, MULTIZONE, MATRIXDeviceSupportenum: RECOMMENDED, COMPATIBLE, NOT_SUPPORTEDEffectInfofrozen dataclass with name, class, description, support mapget_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:
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:
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)returnsTrue, 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():
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 * cyclesseconds - EffectColorloop: Runs indefinitely at calculated FPS
6. State Restoration & Cleanup¶
Conductor restores devices to pre-effect state:
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:
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():
Color State¶
HSBK color captured via get_color():
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 displayAPPLY(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()→Truefor otherEffectColorloopEffectRainbow.inherit_prestate()→Truefor otherEffectRainbowEffectFlame.inherit_prestate()→Truefor otherEffectFlameEffectAurora.inherit_prestate()→Truefor otherEffectAuroraEffectProgress.inherit_prestate()→Truefor otherEffectProgressEffectSunrise.inherit_prestate()→Truefor otherEffectSunriseEffectSunset.inherit_prestate()→Truefor otherEffectSunsetEffectPulsedoesn't use it (returnsFalse)
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
_runningdictionary 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
applylogic - 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:
- Centralized State: Single source of truth for what's running where
- Consistent State Management: All effects get same capture/restore logic
- Concurrency Control: Single lock protects all state modifications
- 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:
- Type Safety: Enforces effect interface at type-check time
- Code Reuse: Common setup logic in
async_perform() - Extensibility: Users can create custom effects easily
- Consistency: All effects follow same pattern
Why Two-Phase Effect Execution?¶
Decision: async_perform() calls async_play() instead of single method.
Rationale:
- Separation of Concerns: Setup logic separate from effect logic
- User Simplicity: Users only override
async_play(), setup is automatic - Consistency: All effects get same power-on behavior
- Flexibility: Base class can add more setup steps without breaking subclasses
Why No Rate Limiting?¶
Decision: Effects don't implement rate limiting.
Rationale:
- Simplicity: Keeps core library simple and focused
- Flexibility: Applications have different rate limit needs
- Transparency: Users see actual device behavior
- 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:
- Device Processing Time: LIFX devices need time to process commands
- Empirical Testing: 0.3s works reliably across all device types
- State Integrity: Prevents race conditions and state corruption
- 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:
- Performance: Eliminates unnecessary state reset
- User Experience: No visible flash between compatible effects
- Opt-in: Only used when effect explicitly enables it
- Safe Default: Returns
Falseunless 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:
- Device Agnostic: Effects work across all device types (Light, MultiZoneLight, MatrixLight) without device-specific code
- Clean Separation: Effect authors implement
generate_frame()returning HSBK colors; animation module handles packet construction, tile mapping, and UDP delivery - Spatial Awareness:
FrameContextprovidespixel_count,canvas_width,canvas_height— enabling 2D effects (fire, rain) on matrix devices - Performance: Direct UDP via prebaked packet templates, no connection overhead
- Smooth Transitions:
duration_msparameter 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 packetsAnimator.for_multizone()— multi-zone SetExtendedColorZones packetsAnimator.for_matrix()— multi-tile Set64 packets with canvas mappinganimator.send_frame()— synchronous frame dispatch (no async overhead)duration_msparameter — 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:
HSBKfor color representation +HSBK.as_tuple()for protocol conversionLightWaveformenum for waveform types (EffectPulse)MultiZoneApplicationRequestfor 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¶
- Getting Started - Basic usage
- Effects Reference - Detailed API documentation
- Custom Effects - Creating your own effects
- Troubleshooting - Common issues