Device API Reference¶
Device state management and emulated device implementation.
The device module provides the core classes for emulating LIFX devices: DeviceState holds all stateful information, and EmulatedLifxDevice processes incoming LIFX protocol packets and generates appropriate responses.
Table of Contents¶
Classes¶
Key Concepts¶
DeviceState¶
Composed dataclass holding all stateful information for an emulated LIFX device.
DeviceState represents the complete state of a virtual LIFX device, including identity (serial, product ID), current settings (color, power, label), capabilities (color, multizone, matrix, etc.), and feature-specific state (zones, tiles, HEV cycle status).
DeviceState is not a flat dataclass: it is composed of focused sub-states (core, network, location, group, waveform, plus optional infrared, hev, multizone and matrix). Its constructor takes those sub-state objects, not keyword arguments such as serial= or label=. See Device State for the full structure.
Create devices with the factory functions
Don't construct DeviceState by hand. Use the factory functions (create_color_light(), create_multizone_light(), create_tile_device(), create_device() and friends), which build the correct sub-states and capability flags from the product registry and specs.yml, then return an EmulatedLifxDevice. The state is available as device.state.
from lifx_emulator.factories import create_color_light
device = create_color_light("d073d5000001")
state = device.state
state.label = "Living Room"
print(state.serial) # d073d5000001
print(state.product) # 91 (LIFX Color, the default for create_color_light)
print(state.core.label) # Living Room
Attributes¶
The attributes below are read and written directly on DeviceState; each one is delegated to the sub-state that owns it (for example, state.label reads state.core.label, and state.zone_count reads state.multizone.zone_count). Default values shown are those used when the device is created by a factory function.
Identity¶
serial(str) - 12-character hexadecimal device serialmac_address(bytes, read-only) - The device's 6-byte MAC address as visible on the network. This is not the serial. On firmware 3.70 to 3.x the MAC address is one more than the serial in the final octet (ffwraps to00); on all other firmware it is the same as the serialvendor(int=1) - LIFX vendor ID (always 1)product(int) - Product ID (e.g., 27 for A19, 32 for Z strip)version_major(int) - Firmware major versionversion_minor(int) - Firmware minor version
Basic State¶
port(int=56700) - UDP port for communicationlabel(str) - Device label (max 32 bytes)power_level(int=0) - Power state (0=off, 65535=on)color(LightHsbk) - Current HSBK coloruptime_ns(int=0) - Device uptime in nanosecondsbuild_timestamp(int) - Firmware build timestamp (Unix epoch)
Capability Flags¶
has_color(bool=True) - Supports full RGB colorhas_infrared(bool=False) - Supports infrared (night vision)has_multizone(bool=False) - Supports multizone (linear strips)has_matrix(bool=False) - Supports matrix (2D tiles)has_chain(bool=False) - Supports multiple tileshas_extended_multizone(bool=False) - Supports the extended multizone protocolhas_hev(bool=False) - Supports HEV (germicidal light)has_relays(bool=False) - Has relays (switches)has_buttons(bool=False) - Has physical buttons (switches)
Location & Group¶
location_id(bytes) - 16-byte location UUID; derived fromlocation_label(uuid5 underLIFX_EMULATOR_NAMESPACE) unless set explicitly, so devices sharing a label share a locationlocation_label(str='Test Location') - Location namelocation_updated_at(int) - Location update timestamp (nanoseconds)group_id(bytes) - 16-byte group UUID; derived fromgroup_labelunless set explicitly, so devices sharing a label share a groupgroup_label(str='Test Group') - Group namegroup_updated_at(int) - Group update timestamp (nanoseconds)
Network¶
wifi_signal(float=-45.0) - WiFi signal strength in dBm
Infrared (Night Vision)¶
infrared_brightness(int=0) - IR brightness (0-65535)
HEV (Germicidal Light)¶
hev_cycle_duration_s(int=7200) - Configured default HEV cycle length in seconds (SetHevCycleConfiguration)hev_cycle_current_duration_s(int | None=None) - Length the current or most recent cycle was started with (SetHevCycle);Noneuntil a cycle has runhev_cycle_remaining_s(int=0) - Remaining time in current cyclehev_cycle_last_power(bool=False) - Whether the light was on (power level non-zero) when SetHevCycle last started a cycle; the power state it returns to when the cycle completeshev_indication(bool=True) - Enable visual indication during cyclehev_last_result(int=0) - Result of last HEV cycle
Multizone (Linear Strips)¶
zone_count(int=0) - Number of zones (0 if not multizone)zone_colors(list[LightHsbk]=[]) - Color for each zonemultizone_pending_zone_colors(dict[int, LightHsbk]={}) - Zone colors staged by aNO_APPLYrequest, not yet shown
Matrix (Tiles)¶
tile_count(int=0) - Number of tiles in chaintile_devices(list[dict]=[]) - Per-tile state (position, colors)tile_width(int=8) - Width of each tile in zones, fixed per product fromspecs.ymltile_height(int=8) - Height of each tile in zones, fixed per product fromspecs.yml
Effects (Waveforms & Animations)¶
waveform_active(bool=False) - Whether a waveform is runningwaveform_type(int=0) - Waveform type (saw, sine, etc.)waveform_transient(bool=False) - Return to original color after waveformwaveform_color(LightHsbk) - Target waveform colorwaveform_period_ms(int=0) - Waveform period in millisecondswaveform_cycles(float=0) - Number of cycles (0 = infinite)waveform_skew_ratio(int=0) - Skew ratio for waveformtransition(TransitionState) - In-flight colour fades, power ramp and the running waveform; see Transitions and waveformsmultizone_effect_type(int=0) - Multizone effect type (move, etc.)multizone_effect_instanceid(int=0) - Instance ID sent with the last SetEffectmultizone_effect_speed_ms(int=5000) - Duration of one effect cycle in millisecondsmultizone_effect_duration(int=0) - Effect run time in nanoseconds (0 = infinite)multizone_effect_parameters(list[int]=[0] * 8) - The eight effect parameters (for MOVE,parameter1is the direction)tile_effect_type(int=0) - Tile effect typetile_effect_instanceid(int=0) - Instance ID sent with the last SetEffecttile_effect_speed_ms(int=5000) - Duration of one effect cycle in millisecondstile_effect_duration(int=0) - Effect run time in nanoseconds (0 = infinite)tile_effect_palette_count(int=0) - Number of colors in effect palettetile_effect_palette(list[LightHsbk]=[]) - Effect palette colorstile_effect_sky_type(int=0) - Sky effect type (sunrise, sunset, clouds)tile_effect_cloud_sat_min(int=0) - Minimum cloud saturation for the Sky effecttile_effect_cloud_sat_max(int=0) - Maximum cloud saturation for the Sky effect
Renamed: multizone_effect_speed and tile_effect_speed
Effect speeds used to be stored in whole seconds as multizone_effect_speed
and tile_effect_speed, which truncated any speed that was not a whole
number of seconds. They are now multizone_effect_speed_ms and
tile_effect_speed_ms, in milliseconds, so GetEffect reports exactly the
speed SetEffect sent. Code that read or wrote the old names must switch to
the new ones and multiply by 1000. Saved device state that still holds the
old seconds value is converted when it is restored.
Methods¶
get_target_bytes() -> bytes¶
Get the 8-byte target field for this device (6-byte serial + 2 null bytes).
Returns: bytes - Target bytes for packet header
Example:
from lifx_emulator.factories import create_color_light
device = create_color_light("d073d5000001")
target = device.state.get_target_bytes()
# Returns: b'\xd0s\xd5\x00\x00\x01\x00\x00'
EmulatedLifxDevice¶
Emulated LIFX device that processes protocol packets and manages state.
EmulatedLifxDevice is the main class for emulating a LIFX device. It receives LIFX protocol packets via process_packet(), updates internal state, and returns appropriate response packets. It supports configurable testing scenarios for error injection, delays, and malformed responses.
Constructor¶
EmulatedLifxDevice(device_state, storage=None, handler_registry=None, scenario_manager=None, on_state_changed=None, persist_initial_state=False, clock=None)¶
Create a new emulated LIFX device. Most code should use the factory functions, which build the DeviceState and the device together.
In most cases you should not call this constructor directly: the factory functions build a fully configured DeviceState for a product and wrap it in an EmulatedLifxDevice for you, and accept the same storage and scenario_manager collaborators.
Parameters:
device_state(DeviceState) - Initial device statestorage(DevicePersistenceAsyncFile | None) - Optional async persistent storage for statehandler_registry(HandlerRegistry | None) - Optional custom packet handler registryscenario_manager(HierarchicalScenarioManager | None) - Optional scenario manager for testing scenarios (see Testing Scenarios)on_state_changed(StateChangeCallback | None) - Optional callback invoked after a state-changing packetpersist_initial_state(bool=False) - Save the initial state as soon as persistence is activatedclock(Callable[[], int] | None) - Monotonic nanosecond clock used for uptime and for evaluating fades and waveforms; defaults totime.monotonic_ns. Inject a fake to test transitions without sleeping
Example:
from lifx_emulator.factories import create_color_light
from lifx_emulator.scenarios import HierarchicalScenarioManager, ScenarioConfig
# Create basic device
device = create_color_light("d073d5000001")
device.state.label = "Living Room"
# Create device with testing scenarios
manager = HierarchicalScenarioManager()
manager.set_device_scenario(
"d073d5000002",
ScenarioConfig(
drop_packets={102: 1.0}, # Drop all SetColor packets (100% drop rate)
response_delays={3: 0.5}, # Delay StateService (3) replies by 500ms
),
)
device = create_color_light("d073d5000002", scenario_manager=manager)
Methods¶
get_uptime_ns() -> int¶
Calculate current uptime in nanoseconds since device creation.
Returns: int - Uptime in nanoseconds
settle() -> None¶
Bring every in-flight fade, power ramp and waveform up to now. Interpolated values are committed into the ordinary colour and power fields and finished transitions are pruned. The device does this itself before processing every packet; call it before reading device.state from outside the packet path. It costs nothing beyond recording the instant when nothing is in flight.
invalidate_scenario_cache() -> None¶
Discard the cached, merged scenario configuration so it is re-resolved from the scenario manager on the next packet. Call this after changing scenarios at runtime (the server's invalidate_all_scenario_caches() does this for every device).
process_packet(header: LifxHeader, packet: Any | None, *, scenario: ScenarioConfig | None = None, should_respond: bool | None = None) -> list[tuple[LifxHeader, Any]]¶
Process an incoming LIFX protocol packet and generate response packets.
This is the main entry point for packet processing. It:
- Routes the packet to the appropriate handler based on packet type
- Applies testing scenarios (delays, drops, malformed responses)
- Returns a list of response packets (header, payload) tuples
Note
Acknowledgment packets (type 45) are normally sent by the server immediately before calling process_packet(). The device only generates acks itself when a scenario targets ack behavior (e.g., delaying, dropping, or corrupting type 45).
Parameters:
header(LifxHeader) - Parsed packet headerpacket(Any | None) - Parsed packet payload (None for header-only packets)scenario(ScenarioConfig | None) - Pre-resolved scenario; resolved from the device's scenario manager when omittedshould_respond(bool | None) - Pre-resolved drop decision; resolved from the scenario when omitted
Returns: list[tuple[LifxHeader, Any]] - List of (header, packet) response tuples to send; packet is raw bytes when a malformed or invalid-field scenario applies
Example:
from lifx_emulator.factories import create_color_light
from lifx_emulator.protocol.header import LifxHeader
from lifx_emulator.protocol.packets import Light
from lifx_emulator.protocol.protocol_types import LightHsbk
device = create_color_light("d073d5000001")
# Build (or unpack from the wire) an incoming SetColor request
packet = Light.SetColor(
color=LightHsbk(hue=21845, saturation=65535, brightness=32768, kelvin=3500),
duration=0,
)
header = LifxHeader(
source=12345,
target=device.state.get_target_bytes(),
res_required=True,
sequence=1,
pkt_type=Light.SetColor.PKT_TYPE,
)
# Process and get responses
responses = device.process_packet(header, packet)
# Each response is a (header, packet) tuple ready to pack and send
for resp_header, resp_packet in responses:
raw_response = resp_header.pack() + resp_packet.pack()
print(resp_header.pkt_type, len(raw_response)) # 107 88 (Light.State)
Capability Flags¶
Capability flags in DeviceState determine which features the device supports and which packet types it can handle.
| Flag | Description | Example Products | Supported Packets |
|---|---|---|---|
has_color |
Full RGB color control | A19 (27), BR30 (44), GU10 (52) | Light.GetColor, Light.SetColor, Light.StateColor |
has_infrared |
Night vision IR capability | LIFX+ A19 (29), LIFX+ BR30 (30) | Light.GetInfrared, Light.SetInfrared, Light.StateInfrared |
has_multizone |
Linear zone control (strips) | LIFX Z (32), Beam (38) | MultiZone.GetColorZones, MultiZone.SetColorZones, MultiZone.StateZone, MultiZone.StateMultiZone |
has_extended_multizone |
Extended multizone support | Beam (38), LIFX Z (32) | MultiZone.ExtendedGetColorZones, MultiZone.ExtendedSetColorZones, MultiZone.ExtendedStateMultiZone |
has_matrix |
2D tile/matrix control | Tile (55), Candle (57), Ceiling (176) | Tile.GetDeviceChain, Tile.Get64, Tile.Set64, Tile.StateDeviceChain, Tile.State64 |
has_chain |
Supports multiple tiles | Tile (55) | Tile.StateDeviceChain may report multiple tile_devices |
has_hev |
Germicidal UV-C light | LIFX Clean (90) | Light.GetHevCycle, Light.SetHevCycle, Light.StateHevCycle |
has_relays |
Relay/switch control | LIFX Switch (70) | Device.* only (returns StateUnhandled for Light/MultiZone/Tile) |
has_buttons |
Physical button configuration | LIFX Switch (70), LIFX Luna (219) | Button.Get, Button.Set, Button.State, Button.GetConfig, Button.SetConfig, Button.StateConfig |
Notes:
- Devices without a capability flag reply
StateUnhandled(223) to related packets - Most devices have
has_color=True(except switches and relays) has_extended_multizoneis independent of zone count — it indicates firmware support for the extended multizone protocol- Matrix devices store tile data in
tile_deviceslist
Capability flags are set by the factory functions from the product registry, so you choose a product rather than setting flags yourself.
Example:
from lifx_emulator.factories import create_device
# Create a multizone device (LIFX Z, product 32) with 16 zones
strip = create_device(32, serial="d073d5000002", zone_count=16)
print(strip.state.has_multizone, strip.state.zone_count) # True 16
# Create a matrix device (LIFX Tile, product 55) with a chain of 5 tiles.
# Tile dimensions are fixed by the product (8x8 for the Tile).
tiles = create_device(55, serial="d073d5000003", tile_count=5)
print(tiles.state.has_matrix, tiles.state.tile_count) # True 5
print(f"{tiles.state.tile_width}x{tiles.state.tile_height}") # 8x8
Testing Scenarios¶
Scenarios configure error injection and testing behaviours for emulated devices. Each scenario is a ScenarioConfig registered with a HierarchicalScenarioManager at device, type, location, group or global scope; pass the manager to the factory function (or EmulatedLifxDevice) as scenario_manager. This is useful for testing client library error handling, timeouts, and edge cases.
Available Scenarios¶
| Scenario | Type | Description | Example |
|---|---|---|---|
drop_packets |
dict[int, float] |
Packet types to drop with rates (0.0-1.0) | {102: 1.0, 101: 0.5} - Always drop SetColor, drop Get 50% |
response_delays |
dict[int, float] |
Delay (seconds) before sending the outgoing response type | {3: 1.5} - Delay StateService (reply to GetService) by 1.5s |
malformed_packets |
list[int] |
Packet types to send truncated/corrupted | [107] - Corrupt StateColor packets |
invalid_field_values |
list[int] |
Packet types to send with invalid fields (0xFF) | [107] - Invalid StateColor values |
partial_responses |
list[int] |
Multizone/tile packets to send incomplete | [506] - Partial zone data |
firmware_version |
tuple[int, int] \| None |
Override the version reported in StateHostFirmware |
(2, 80) - Report v2.80 |
send_unhandled |
bool |
Send StateUnhandled (223) for unsupported packets |
False - Stay silent instead |
Examples¶
from lifx_emulator.factories import create_color_light, create_multizone_light
from lifx_emulator.scenarios import HierarchicalScenarioManager, ScenarioConfig
manager = HierarchicalScenarioManager()
Simulate network issues:
manager.set_device_scenario(
"d073d5000001",
ScenarioConfig(
drop_packets={2: 1.0}, # Drop all GetService packets - simulate discovery failure
response_delays={107: 2.0}, # Delay StateColor (reply to SetColor) by 2 seconds
),
)
device = create_color_light("d073d5000001", scenario_manager=manager)
Test error handling:
manager.set_device_scenario(
"d073d5000002",
ScenarioConfig(
malformed_packets=[107], # Corrupt Light.State responses
invalid_field_values=[118], # Invalid Light.StatePower values
),
)
device = create_color_light("d073d5000002", scenario_manager=manager)
Test multizone edge cases:
manager.set_type_scenario(
"multizone",
ScenarioConfig(partial_responses=[506]), # Send incomplete StateMultiZone packets
)
strip = create_multizone_light("d073d5000003", extended_multizone=False, scenario_manager=manager)
Test firmware compatibility:
manager.set_global_scenario(
ScenarioConfig(firmware_version=(2, 77)), # Report older firmware version
)
device = create_color_light("d073d5000004", scenario_manager=manager)
If you change scenarios after devices have processed packets, call device.invalidate_scenario_cache() (or server.invalidate_all_scenario_caches()) so the new configuration takes effect.
State Access Patterns¶
Reading State¶
Access device state directly through the state attribute:
from lifx_emulator.factories import create_multizone_light
device = create_multizone_light("d073d5000002", zone_count=16)
# Check power
if device.state.power_level == 65535:
print("Device is on")
# Check color
print(f"Hue: {device.state.color.hue}")
print(f"Brightness: {device.state.color.brightness}")
# Check zones (multizone)
if device.state.has_multizone:
for i, color in enumerate(device.state.zone_colors):
print(f"Zone {i}: {color}")
Modifying State¶
Modify state attributes directly; each assignment is routed to the owning sub-state:
from lifx_emulator.factories import create_color_light
from lifx_emulator.protocol.protocol_types import LightHsbk
device = create_color_light("d073d5000001")
# Change color
device.state.color = LightHsbk(hue=21845, saturation=65535, brightness=32768, kelvin=3500)
# Change label
device.state.label = "Kitchen Light"
# Power on
device.state.power_level = 65535
State changes made by protocol packets (SetColor, SetPower, SetLabel and so on) are queued for saving automatically when the device has storage. Direct attribute assignments like the ones above are not; queue a save yourself with await device.storage.save_device_state(device.state).
Persistent Storage Integration¶
Use DevicePersistenceAsyncFile to persist state across restarts. Pass the same storage and serial to a factory function on the next run and the saved state is restored:
import asyncio
from lifx_emulator.devices import DevicePersistenceAsyncFile
from lifx_emulator.factories import create_color_light
async def main():
storage = DevicePersistenceAsyncFile() # Uses ~/.lifx-emulator by default
device = create_color_light("d073d5000001", storage=storage)
device.state.label = "Kitchen Light"
# Queue an async save, then flush pending writes before exiting
await storage.save_device_state(device.state)
await storage.shutdown()
# On the next run, state is restored from ~/.lifx-emulator/d073d5000001.json
restored = create_color_light("d073d5000001", storage=DevicePersistenceAsyncFile())
print(restored.state.label) # Kitchen Light
asyncio.run(main())
Transitions and waveforms¶
A real bulb fades to a SetColor over the packet's duration and runs a SetWaveform for cycles periods; the emulator reproduces both without timers or threads. The handler records where each zone is going and the device evaluates the record lazily, against its monotonic clock, whenever state is read:
Light.SetColor,MultiZone.SetColorZones,MultiZone.ExtendedSetColorZonesandTile.Set64(framebuffer 0 only) record a per-zone fade whenduration > 0. Hue takes the shortest path around the colour wheel; saturation, brightness and kelvin are linear. A new fade starts from the zone's current colour and replaces any fade already running on that zone. Zones staged withNO_APPLYstart fading only whenAPPLYorAPPLY_ONLYflushes them, over the flushing packet's duration.Light.SetPowerwith a duration records a power ramp andLightGetPowerreports the interpolated level.Device.SetPowerhas no duration and applies at once.Light.SetWaveformandLight.SetWaveformOptionalrun SAW, SINE, HALF_SINE, TRIANGLE and PULSE as the LIFX waveform documentation describes. PULSE holds the target colour for a duty cycle of1 - skew_ratio(scaled from the signed 16-bit range to 0..1). A transient waveform restores each zone's start colour when it completes; a non-transient SAW, HALF_SINE or PULSE leaves the waveform colour, while SINE and TRIANGLE always return to the start colour, as the documentation specifies. A period of 0, a cycle count that is not positive and finite, or an unknown type is ignored, thoughLightStateis still returned when requested.- Any colour
Setcancels a running waveform at the colour it had reached.Tile.CopyFrameBufferand writes to framebuffers 1-7 are immediate. - The reply to a
Setreports the state at the time of the reply, which for a non-zero duration is the start of the fade.
Inject a clock to test this deterministically:
from lifx_emulator.factories import create_color_light
from lifx_emulator.protocol.header import LifxHeader
from lifx_emulator.protocol.packets import Light
from lifx_emulator.protocol.protocol_types import LightHsbk
now_ns = [0]
device = create_color_light("d073d5000001", clock=lambda: now_ns[0])
header = LifxHeader(
source=1, target=device.state.get_target_bytes(), sequence=1, pkt_type=102
)
device.process_packet(
header,
Light.SetColor(
color=LightHsbk(hue=0, saturation=65535, brightness=65535, kelvin=3500),
duration=1000,
),
)
now_ns[0] += 500_000_000 # half a second later
((_, state),) = device.process_packet(
LifxHeader(
source=1, target=device.state.get_target_bytes(), sequence=2, pkt_type=101
),
Light.GetColor(),
)
print(state.color.brightness) # roughly half way to 65535
Packet Processing Flow¶
The packet processing flow in EmulatedLifxDevice.process_packet() follows these steps:
graph TD
S[Server receives packet] --> SA{ack_required?}
SA -->|Yes| SB{Scenario targets acks?}
SA -->|No| A
SB -->|No| SC[Server sends ack immediately]
SB -->|Yes| A
SC --> A
A[process_packet called] --> A2[settle fades and waveforms to now]
A2 --> B{Drop packet?}
B -->|Yes| L[Return empty]
B -->|No| C[Route to handler]
C --> D["handler.handle(state, packet, res_required)"]
D --> E{Handler returns response?}
E -->|No| L
E -->|Yes| F{Malformed?}
F -->|Yes| G[Truncate packet]
F -->|No| H{Invalid fields?}
G --> J[Return responses]
H -->|Yes| I[Set fields to 0xFF]
H -->|No| J
I --> J
Key Points:
- Every packet first settles in-flight fades, power ramps and waveforms to the current instant, so handlers read and reply with evaluated state (see Transitions and waveforms)
- Acknowledgments (packet type 45) are sent by the server immediately before
process_packet()for minimal latency. When a scenario targets ack behavior, the device generates the ack instead so it goes through scenario processing. res_requiredis passed to the handler, which decides whether to return a response based on its value- Handlers are registered by packet type and dispatched via
HandlerRegistry - Testing scenarios (malformed, invalid fields) are applied after handler execution, before returning responses
- Multiple response packets may be returned (e.g., multizone queries return multiple
StateMultiZonepackets)
See Also:
- EmulatedLifxServer - UDP server that routes packets to devices
- Protocol Packets - LIFX protocol packet definitions
- Factories - Helper functions for creating pre-configured devices
- Storage - Persistent state storage API
References¶
Source: packages/lifx-emulator-core/src/lifx_emulator/devices/device.py, packages/lifx-emulator-core/src/lifx_emulator/devices/states.py
Related Documentation:
- Getting Started - Quick start guide
- Device Types - Supported device types and capabilities
- Testing Scenarios - Detailed testing scenario guide
- Architecture Overview - System architecture