Device State¶
How device state is structured, accessed, and updated.
Overview¶
Each emulated device holds a DeviceState object that represents its complete state: identity, capabilities, light settings, zone colors, tile data, and more. The state is composed from focused sub-state dataclasses following the Single Responsibility Principle.
Composed State Architecture¶
DeviceState is not a monolithic dataclass. It delegates to specialized sub-state objects:
DeviceState
├── core: CoreDeviceState (serial, label, power, color, firmware)
├── network: NetworkState (WiFi signal strength)
├── location: LocationState (location ID, label, timestamp)
├── group: GroupState (group ID, label, timestamp)
├── waveform: WaveformState (waveform effect parameters)
├── transition: TransitionState (in-flight fades, power ramps and waveform)
├── infrared: InfraredState | None (IR brightness — color lights with IR only)
├── hev: HevState | None (HEV cycle state — LIFX Clean only)
├── multizone: MultiZoneState | None (zone count, zone colors, effects)
└── matrix: MatrixState | None (tile count, dimensions, tile data, framebuffers)
The first six sub-states are always present. The last four are None when the device lacks the corresponding capability.
Capability Flags¶
Boolean flags indicate what a device supports:
| Flag | Meaning | Enables |
|---|---|---|
has_color |
Full RGB color | Hue/saturation control |
has_infrared |
IR LED | InfraredState sub-state |
has_multizone |
Linear zones | MultiZoneState sub-state |
has_extended_multizone |
Extended multizone protocol | 82-zone packets |
has_matrix |
2D zone grid | MatrixState sub-state |
has_hev |
Germicidal UV-C | HevState sub-state |
has_relays |
Physical relays | Switch behavior |
has_buttons |
Physical buttons | Button protocol (types 905–911) |
These flags are set by factory functions based on the product specification and cannot change at runtime.
Attribute Delegation¶
Rather than defining hundreds of property getter/setter methods, DeviceState uses __getattr__ and __setattr__ with a routing map to delegate attribute access to the correct sub-state:
state.label # → state.core.label
state.wifi_signal # → state.network.wifi_signal
state.zone_count # → state.multizone.zone_count (or 0 if multizone is None)
state.tile_width # → state.matrix.tile_width (or 8 if matrix is None)
The _ATTRIBUTE_ROUTES dictionary maps attribute names to their sub-state. Some attributes use tuple routing for name translation:
_ATTRIBUTE_ROUTES = {
# ...
"multizone_effect_type": ("multizone", "effect_type"),
# state.multizone_effect_type → state.multizone.effect_type
}
When an optional sub-state is None, reads return a default value from _OPTIONAL_DEFAULTS and writes are silently ignored.
Sub-State Details¶
CoreDeviceState¶
The essential identity and light state every device has:
serial— 12-character hex string (e.g.,"d073d5000001")label— Device name (up to 32 bytes)power_level— 0 (off) or 65535 (on)color—LightHsbk(hue, saturation, brightness, kelvin)vendor,product— Product identificationversion_major,version_minor— Firmware versionbuild_timestamp— Firmware build time (seconds since epoch)mac_address,port— Network identity
MultiZoneState¶
For devices with independently controllable linear zones (strips, beams):
zone_count— Number of zones (product-dependent; e.g., LIFX Z: 8–16, Beam: 10–82, Neon: 24–48)zone_colors— List ofLightHsbk, one per zone (what the strip is showing)pending_zone_colors— Colours staged by aNO_APPLYrequest, keyed by zone index, until anAPPLYorAPPLY_ONLYrequest shows themeffect_type,effect_instanceid,effect_speed_ms,effect_duration,effect_parameters— Multizone effect state, stored exactly as the last SetEffect sent it
Zone count ranges are defined per product in specs.yml. Zone colors are initialized to a rainbow pattern by EmulatedLifxDevice.__init__() if not provided.
MatrixState¶
For devices with a 2D zone grid (tiles, candles, ceilings):
tile_count— Number of tiles in the chain (product-dependent; LIFX Tile: 1–5, most other matrix devices: 1 only)tile_width,tile_height— Dimensions of each tile (product-dependent; e.g., Tile: 8x8, Candle: 5x6, Ceiling: 8x8)tile_devices— List of tile metadata dicts (position, colors, dimensions)tile_framebuffers— List ofTileFramebuffersfor non-visible bufferseffect_type,effect_instanceid,effect_speed_ms,effect_duration,effect_palette_count,effect_palette,effect_sky_type,effect_cloud_sat_min,effect_cloud_sat_max— Matrix effect state, stored exactly as the last SetEffect sent it
Tile count ranges and dimensions are defined per product in specs.yml. Each tile's visible colors are in tile_devices[i]["colors"]. Non-visible framebuffers are stored separately in TileFramebuffers and lazily initialized on first access.
HevState¶
For LIFX Clean devices with germicidal UV-C:
hev_cycle_duration_s— Configured default cycle length (default: 7200s / 2 hours)hev_cycle_current_duration_s— Length the current or most recent cycle was started with; a SetHevCycle duration of 0 uses the configured defaulthev_cycle_remaining_s— Time remaining in active cyclehev_cycle_last_power— Whether the light was on (power level non-zero) when the current or most recent cycle startedhev_indication— Whether HEV indicator light is enabledhev_last_result— Result code of last HEV cycle
State Updates¶
Handlers update state by writing directly to DeviceState attributes:
# In a handler's handle() method:
device_state.color = packet.color
device_state.power_level = 65535
These writes are routed through __setattr__ to the correct sub-state object. The device's process_packet() method calls the handler and then optionally triggers a persistence save if storage is configured.
TransitionState¶
A Set with a non-zero duration, and any waveform, does not write its final colour straight away. Instead the handler records what the device is moving towards in state.transition:
zones— Per-zone colour fades (from_color,to_color,start_ns,end_ns), keyed by a flat zone index: a colour light has zone 0, a multizone light has zone0tozone_count - 1and a matrix device has one zone per framebuffer-0 cell, tile by tilepower— An optionalPowerEnveloperecorded byLightSetPowerwith a durationwaveform— At most oneActiveWaveformholding each zone's start and target colours plus the period, cycles, skew ratio, type and transient flagsettled_ns— The monotonic instant the state was last evaluated to; new transitions start here, so handlers never touch the clockis_active— Whether anything is in flight
Nothing ticks in the background. EmulatedLifxDevice.settle() evaluates every record against the device's monotonic clock, commits the interpolated values into the ordinary colour and power fields, and prunes what has finished. The device settles itself at the start of every process_packet() call and the management API does the same before reading, so a LightGet mid-fade returns the interpolated colour and a waveform follows its documented curve. The evaluation code lives in devices/transitions.py.
TransitionState is never persisted: the serialiser writes the end value of every active fade or waveform, so a restored device lands where the change would have finished with nothing in flight.
See Also¶
- Architecture Overview — Component relationships
- Packet Flow — How handlers interact with state
- Protocol Layer — Packet structures that carry state