Skip to content

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 serial
  • mac_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 (ff wraps to 00); on all other firmware it is the same as the serial
  • vendor (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 version
  • version_minor (int) - Firmware minor version

Basic State

  • port (int = 56700) - UDP port for communication
  • label (str) - Device label (max 32 bytes)
  • power_level (int = 0) - Power state (0=off, 65535=on)
  • color (LightHsbk) - Current HSBK color
  • uptime_ns (int = 0) - Device uptime in nanoseconds
  • build_timestamp (int) - Firmware build timestamp (Unix epoch)

Capability Flags

  • has_color (bool = True) - Supports full RGB color
  • has_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 tiles
  • has_extended_multizone (bool = False) - Supports the extended multizone protocol
  • has_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 from location_label (uuid5 under LIFX_EMULATOR_NAMESPACE) unless set explicitly, so devices sharing a label share a location
  • location_label (str = 'Test Location') - Location name
  • location_updated_at (int) - Location update timestamp (nanoseconds)
  • group_id (bytes) - 16-byte group UUID; derived from group_label unless set explicitly, so devices sharing a label share a group
  • group_label (str = 'Test Group') - Group name
  • group_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); None until a cycle has run
  • hev_cycle_remaining_s (int = 0) - Remaining time in current cycle
  • hev_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 completes
  • hev_indication (bool = True) - Enable visual indication during cycle
  • hev_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 zone
  • multizone_pending_zone_colors (dict[int, LightHsbk] = {}) - Zone colors staged by a NO_APPLY request, not yet shown

Matrix (Tiles)

  • tile_count (int = 0) - Number of tiles in chain
  • tile_devices (list[dict] = []) - Per-tile state (position, colors)
  • tile_width (int = 8) - Width of each tile in zones, fixed per product from specs.yml
  • tile_height (int = 8) - Height of each tile in zones, fixed per product from specs.yml

Effects (Waveforms & Animations)

  • waveform_active (bool = False) - Whether a waveform is running
  • waveform_type (int = 0) - Waveform type (saw, sine, etc.)
  • waveform_transient (bool = False) - Return to original color after waveform
  • waveform_color (LightHsbk) - Target waveform color
  • waveform_period_ms (int = 0) - Waveform period in milliseconds
  • waveform_cycles (float = 0) - Number of cycles (0 = infinite)
  • waveform_skew_ratio (int = 0) - Skew ratio for waveform
  • transition (TransitionState) - In-flight colour fades, power ramp and the running waveform; see Transitions and waveforms
  • multizone_effect_type (int = 0) - Multizone effect type (move, etc.)
  • multizone_effect_instanceid (int = 0) - Instance ID sent with the last SetEffect
  • multizone_effect_speed_ms (int = 5000) - Duration of one effect cycle in milliseconds
  • multizone_effect_duration (int = 0) - Effect run time in nanoseconds (0 = infinite)
  • multizone_effect_parameters (list[int] = [0] * 8) - The eight effect parameters (for MOVE, parameter1 is the direction)
  • tile_effect_type (int = 0) - Tile effect type
  • tile_effect_instanceid (int = 0) - Instance ID sent with the last SetEffect
  • tile_effect_speed_ms (int = 5000) - Duration of one effect cycle in milliseconds
  • tile_effect_duration (int = 0) - Effect run time in nanoseconds (0 = infinite)
  • tile_effect_palette_count (int = 0) - Number of colors in effect palette
  • tile_effect_palette (list[LightHsbk] = []) - Effect palette colors
  • tile_effect_sky_type (int = 0) - Sky effect type (sunrise, sunset, clouds)
  • tile_effect_cloud_sat_min (int = 0) - Minimum cloud saturation for the Sky effect
  • tile_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 state
  • storage (DevicePersistenceAsyncFile | None) - Optional async persistent storage for state
  • handler_registry (HandlerRegistry | None) - Optional custom packet handler registry
  • scenario_manager (HierarchicalScenarioManager | None) - Optional scenario manager for testing scenarios (see Testing Scenarios)
  • on_state_changed (StateChangeCallback | None) - Optional callback invoked after a state-changing packet
  • persist_initial_state (bool = False) - Save the initial state as soon as persistence is activated
  • clock (Callable[[], int] | None) - Monotonic nanosecond clock used for uptime and for evaluating fades and waveforms; defaults to time.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:

  1. Routes the packet to the appropriate handler based on packet type
  2. Applies testing scenarios (delays, drops, malformed responses)
  3. 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 header
  • packet (Any | None) - Parsed packet payload (None for header-only packets)
  • scenario (ScenarioConfig | None) - Pre-resolved scenario; resolved from the device's scenario manager when omitted
  • should_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_multizone is independent of zone count — it indicates firmware support for the extended multizone protocol
  • Matrix devices store tile data in tile_devices list

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.ExtendedSetColorZones and Tile.Set64 (framebuffer 0 only) record a per-zone fade when duration > 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 with NO_APPLY start fading only when APPLY or APPLY_ONLY flushes them, over the flushing packet's duration.
  • Light.SetPower with a duration records a power ramp and LightGetPower reports the interpolated level. Device.SetPower has no duration and applies at once.
  • Light.SetWaveform and Light.SetWaveformOptional run SAW, SINE, HALF_SINE, TRIANGLE and PULSE as the LIFX waveform documentation describes. PULSE holds the target colour for a duty cycle of 1 - 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, though LightState is still returned when requested.
  • Any colour Set cancels a running waveform at the colour it had reached. Tile.CopyFrameBuffer and writes to framebuffers 1-7 are immediate.
  • The reply to a Set reports 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_required is 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 StateMultiZone packets)

See Also:


References

Source: packages/lifx-emulator-core/src/lifx_emulator/devices/device.py, packages/lifx-emulator-core/src/lifx_emulator/devices/states.py

Related Documentation: