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) │
│ • EffectPulse, EffectColorloop (implementations) │
│ • LIFXEffect (base class) │
└────────────────┬────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────┐
│ Device Layer (lifx.devices) │
│ • Light, MultiZoneLight, MatrixLight │
│ • Device state methods (get_color, set_color) │
└────────────────┬────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────┐
│ Network Layer (lifx.network) │
│ • DeviceConnection (UDP transport) │
│ • Message building and parsing │
└────────────────┬────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────┐
│ Protocol Layer (lifx.protocol) │
│ • Binary serialization/deserialization │
│ • Packet definitions (auto-generated) │
└─────────────────────────────────────────────────────┘
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
├── conductor.py # Conductor orchestrator
├── pulse.py # EffectPulse implementation
├── colorloop.py # EffectColorloop implementation
├── models.py # PreState, RunningEffect dataclasses
└── utils.py # Shared utilities (future)
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)
...
Effect Implementations¶
EffectPulse (pulse.py):
- Implements pulse/blink/breathe effects
- Five modes with different timing and waveforms
- Intelligent color selection based on mode
- Auto-completion after configured cycles
EffectColorloop (colorloop.py):
- Implements continuous hue rotation
- Randomized direction, device order, saturation
- Runs indefinitely until stopped
- Supports state inheritance for seamless transitions
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. 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
4. Effect Execution¶
Effect logic runs via async_play():
What happens:
- Subclass-specific effect logic executes
- Can access
self.participantsandself.conductor - Can issue commands to devices
- Pulse effects: Send waveform, wait for completion
- ColorLoop effects: Continuous loop until stopped
Timing:
- EffectPulse:
period * cyclesseconds - EffectColorloop: Runs indefinitely
5. State Restoration¶
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()→ ReturnsTruefor otherEffectColorloopinstancesEffectPulsedoesn'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)¶
- Current Implementation: Treated like color lights (no matrix-specific logic yet)
- Future Enhancement: Could apply effects to individual tiles using device chain
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 No Tile-Specific Logic (Yet)?¶
Decision: Tiles treated like single-color lights for now.
Rationale:
- MVP Scope: Phase 1 focuses on core framework
- Complexity: Tile effects require 2D coordinate system
- Future Enhancement: Architecture supports adding tile-specific effects later
- Current Usefulness: Effects still work on tiles (just not tile-aware)
Future Work: Tile-specific effects would use MatrixLight.set_matrix_colors() and apply per-tile logic similar to theme support.
Integration Points¶
With Device Layer¶
Effects use standard device methods:
get_power(),set_power()get_color(),set_color()set_waveform()(EffectPulse)get_color_zones(),set_color_zones()(MultiZoneLight)get_extended_color_zones(),set_extended_color_zones()(MultiZoneLight)
No special device modifications needed.
With Network Layer¶
Effects rely 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
- No effect-specific network code needed
With Protocol Layer¶
Effects use existing protocol structures:
HSBKfor color representationLightWaveformenum for waveform typesMultiZoneApplicationRequestfor zone apply logic- 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
- ColorLoop: 1 color packet per device per iteration
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