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 (sends waveforms via Device) │
│ • FrameEffect → Colorloop, Rainbow, Flicker, │
│ 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
├── flicker.py # EffectFlicker (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() - Keep
inherit_prestate()for compatibility (superseded; no longer consulted) - 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): # Superseded; not consulted
...
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 - Sends one waveform to each participant, which the light then runs itself
- 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
EffectFlicker (flicker.py):
- Renamed from
EffectFlame, which remains as a deprecated alias, because "Flame" is the name of the firmware effect that matrix lights run themselves - 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 software 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)
tile_colors: list[list[HSBK]] | None # Every tile's colours (matrix lights)
stored_colors: dict[str, list[HSBK] | None] | None # Ceiling/Mirror components
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. If the light already runs an effect, on this or any other Conductor,
take it out of that run with no restore (newest wins); the old run's
other lights carry on
2. Check if prestate can be inherited from that effect
3. 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)
4. Store in RunningEffect and register in conductor._running
Timing: <1 second per device (mostly network I/O)
Special Cases:
- Prestate Inheritance: A participant taken over from a running effect always reuses the replaced run's original PreState, so a later stop restores what was there before any effect
- 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. Borrow the light's own Animator: await light.animator.prepare()
(the first prepare queries tile info or zone count; later ones do not)
2. Draw through a writer on that Animator with the effect's own canvas
and duration_ms = int(1500 / effect.fps), so transitions overlap:
- MirrorLight → one 25x1 ring that wraps, scattered to both rings
- every other light → the device's own canvas
3. Call effect.async_setup(participants) for pre-loop initialization
Note: Animators use direct UDP, so no device connection is needed after preparation. Each light owns one Animator, so an effect and a direct frame sender such as LedFx share one writer and one ack gate. Stopping an effect releases its writers; the light's Animator stays open.
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:
if not isinstance(light, MatrixLight):
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. Matrix lights (including Ceiling and Mirror) skip to step 3: no colour
is written, so neither light component's stored colours change
2. Get startup colour from from_poweroff_hsbk() and set it (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
Matrix State¶
For MatrixLight devices (Tile, Candle, Path, Ceiling and Mirror), every
tile's colours are captured with get_all_tile_colors(), so the light gets
back the whole tile it showed, not one colour. If the read fails, restoration
falls back to the single captured colour.
Ceiling and Mirror lights also record both light components' stored colours. Restoring the tile and power, and any tile read while frames play, can change them; the restore puts the recorded colours back last, so an effect never changes what a component turns back on to.
State Restoration Details¶
Matrix Restoration¶
Each captured tile is written back with set_matrix_colors(tile_index,
colours, duration=0), instead of the single colour, before power is
restored.
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¶
When a new effect replaces a running one on a light or light component, on any Conductor, the new run inherits the replaced run's original prior state, so a later stop restores what was there before any effect rather than a frame captured mid-effect.
inherit_prestate() is superseded: the Conductor no longer consults it, and overriding it has no effect. It stays on LIFXEffect for compatibility.
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: Flicker (vertical gradient), Aurora (vertical brightness), Sunrise/Sunset (radial wavefront)
- Canvas mapping: Multi-tile devices get a unified canvas based on tile positions
- Mirror: Each ring, front and back, is a 25x1 canvas running clockwise from the ring
origin (top centre by default, set with
MirrorLight.ring_origin), viewed from the front, withFrameContext.wrapsset. The back ring's zones run anticlockwise, so its slot takes them in reverse, level with the front, and both rings turn the same way. The origin is read when a writer is created, so a change applies from the next effect start. A whole-light frame effect runs as two ring participants that share the Mirror's onedevice_indexand simulation, so both rings show the same frame and the two chipless positions stay dark. An effect onmirror.frontormirror.backdraws on that ring alone. Each ring is a slot on the light's Animator, which scatters the ring's frame to its buffer positions and sends the whole tile in one Set64. The Mirror's ownsend_frame()still exposes the raw 4x13 tile.
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_heightandwraps, enabling 2D effects (fire, rain) on matrix devices and seamless patterns on ring canvases - 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 sends one waveform per participant)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:
light.animator: the one Animator each light owns: SetColor packets for a single light, SetExtendedColorZones for a strip, Set64 with canvas mapping for a matrixanimator.send_frame()— synchronous frame dispatch (no async overhead)duration_ms: firmware-level interpolation between frames, set per writer
The Conductor borrows each light's Animator in start() and releases it 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