Skip to content

Network Layer

The network layer provides low-level operations for communicating with LIFX devices over UDP.

For the consumer-facing discovery journey (discover(), discover_udp(), discover_mdns(), targeted lookup, method selection and limitations), see the Discovery Guide. This page documents the low-level discovery, transport and connection primitives underneath it.

Discovery

Functions for discovering LIFX devices on the local network.

discover_devices async

discover_devices(
    timeout: float = DISCOVERY_TIMEOUT,
    broadcast_address: str = "255.255.255.255",
    port: int = LIFX_UDP_PORT,
    max_response_time: float = MAX_RESPONSE_TIME,
    idle_timeout_multiplier: float = IDLE_TIMEOUT_MULTIPLIER,
    device_timeout: float = DEFAULT_REQUEST_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
) -> AsyncGenerator[DiscoveredDevice, None]

Discover LIFX devices on the local network.

Sends a broadcast DeviceGetService packet and yields devices as they respond. The packet is re-broadcast several times on an escalating schedule within the discovery window, so devices behind a lossy access point that miss the first broadcast are still found. Implements DoS protection via timeout, source validation, and serial validation. Serial validation and per-serial deduplication are enforced inside _discover_with_packet, so every caller of that shared generator benefits.

Note

On a populated network, generator completion now typically takes longer than a single broadcast alone would: re-broadcasts continue for several seconds into the window, and the generator then waits out the ~4 s idle window (max_response_time × idle_timeout_multiplier) after the last response -- still well inside DISCOVERY_TIMEOUT (15 s). Streaming consumers (async for) see the first devices at unchanged latency; only overall completion moves later, because later re-broadcasts legitimately keep finding new devices and resetting the idle window.

The idle window measures network silence, not elapsed wall time: time the consumer spends inside the async for body is excluded, so a slow consumer no longer shortens the sweep. timeout (default 15 s) is the bound that still applies to it.

PARAMETER DESCRIPTION
timeout

Discovery timeout in seconds

TYPE: float DEFAULT: DISCOVERY_TIMEOUT

broadcast_address

Broadcast address to use

TYPE: str DEFAULT: '255.255.255.255'

port

UDP port to use (default LIFX_UDP_PORT)

TYPE: int DEFAULT: LIFX_UDP_PORT

max_response_time

Max time to wait for responses

TYPE: float DEFAULT: MAX_RESPONSE_TIME

idle_timeout_multiplier

Idle timeout multiplier

TYPE: float DEFAULT: IDLE_TIMEOUT_MULTIPLIER

device_timeout

Request timeout set on discovered devices

TYPE: float DEFAULT: DEFAULT_REQUEST_TIMEOUT

max_retries

Max retries per request set on discovered devices

TYPE: int DEFAULT: DEFAULT_MAX_RETRIES

YIELDS DESCRIPTION
AsyncGenerator[DiscoveredDevice, None]

DiscoveredDevice instances as they are discovered

AsyncGenerator[DiscoveredDevice, None]

(deduplicated by serial number)

Example
# Process devices as they're discovered
async for device in discover_devices(timeout=5.0):
    print(f"Found device: {device.serial} at {device.ip}:{device.port}")

# Or collect all devices first
devices = []
async for device in discover_devices():
    devices.append(device)
Source code in src/lifx/network/discovery/udp.py
async def discover_devices(
    timeout: float = DISCOVERY_TIMEOUT,
    broadcast_address: str = "255.255.255.255",
    port: int = LIFX_UDP_PORT,
    max_response_time: float = MAX_RESPONSE_TIME,
    idle_timeout_multiplier: float = IDLE_TIMEOUT_MULTIPLIER,
    device_timeout: float = DEFAULT_REQUEST_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
) -> AsyncGenerator[DiscoveredDevice, None]:
    """Discover LIFX devices on the local network.

    Sends a broadcast DeviceGetService packet and yields devices as they respond.
    The packet is re-broadcast several times on an escalating schedule within
    the discovery window, so devices behind a lossy access point that miss
    the first broadcast are still found. Implements DoS protection via
    timeout, source validation, and serial validation. Serial validation and
    per-serial deduplication are enforced inside ``_discover_with_packet``,
    so every caller of that shared generator benefits.

    Note:
        On a populated network, generator *completion* now typically takes
        longer than a single broadcast alone would: re-broadcasts continue
        for several seconds into the window, and the generator then waits
        out the ~4 s idle window (``max_response_time`` ×
        ``idle_timeout_multiplier``) after the last response -- still well
        inside ``DISCOVERY_TIMEOUT`` (15 s). Streaming consumers
        (``async for``) see the first devices at unchanged latency; only
        overall completion moves later, because later re-broadcasts
        legitimately keep finding new devices and resetting the idle
        window.

        The idle window measures network silence, not elapsed wall time:
        time the consumer spends inside the ``async for`` body is excluded,
        so a slow consumer no longer shortens the sweep. ``timeout``
        (default 15 s) is the bound that still applies to it.

    Args:
        timeout: Discovery timeout in seconds
        broadcast_address: Broadcast address to use
        port: UDP port to use (default LIFX_UDP_PORT)
        max_response_time: Max time to wait for responses
        idle_timeout_multiplier: Idle timeout multiplier
        device_timeout: Request timeout set on discovered devices
        max_retries: Max retries per request set on discovered devices

    Yields:
        DiscoveredDevice instances as they are discovered
        (deduplicated by serial number)

    Example:
        ```python
        # Process devices as they're discovered
        async for device in discover_devices(timeout=5.0):
            print(f"Found device: {device.serial} at {device.ip}:{device.port}")

        # Or collect all devices first
        devices = []
        async for device in discover_devices():
            devices.append(device)
        ```
    """
    observer = _current_discovery_observer()
    responses = _discover_with_packet(
        DevicePackets.GetService(),
        timeout=timeout,
        broadcast_address=broadcast_address,
        port=port,
        max_response_time=max_response_time,
        idle_timeout_multiplier=idle_timeout_multiplier,
        _observer=observer,
    )
    async with aclosing(responses):
        async for resp in responses:
            # Device's authoritative service port comes from the StateService
            # payload (D-05). resp.port is only the device's source port (addr[1]) —
            # prefer the reported service port here (Pitfall 2).
            device_port: int = resp.response_payload["port"]
            yield DiscoveredDevice(
                serial=resp.serial,
                ip=resp.ip,
                port=device_port,
                response_time=resp.response_time,
                timeout=device_timeout,
                max_retries=max_retries,
            )

DiscoveredDevice dataclass

DiscoveredDevice(
    serial: str,
    ip: str,
    port: int = LIFX_UDP_PORT,
    timeout: float = DEFAULT_REQUEST_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
    first_seen: float = time(),
    response_time: float = 0.0,
)

Information about a discovered LIFX device.

ATTRIBUTE DESCRIPTION
serial

Device serial number as 12-digit hex string (e.g., "d073d5123456")

TYPE: str

ip

Device IP address

TYPE: str

port

Device UDP port

TYPE: int

first_seen

Timestamp when device was first discovered

TYPE: float

response_time

Response time in seconds, anchored at the first broadcast (time since discovery began). A device answering a later re-broadcast reports a proportionally larger value.

TYPE: float

METHOD DESCRIPTION
create_device

Create appropriate device instance based on product capabilities.

__hash__

Hash based on serial number for deduplication.

__eq__

Equality based on serial number.

Methods:

create_device async
create_device()

Create appropriate device instance based on product capabilities.

Queries the device for its product ID and uses the product registry to instantiate the appropriate device class (Device, Light, HevLight, InfraredLight, MultiZoneLight, MatrixLight, or CeilingLight) based on the product capabilities.

This is the single source of truth for device type detection and instantiation across the library.

RETURNS DESCRIPTION

Device instance of the appropriate type

RAISES DESCRIPTION
TypeError

If a concrete device constructor no longer accepts the shared discovery arguments.

AttributeError

If an internal device capability or metadata contract is broken.

Example
async for discovered in discover_devices():
    device = await discovered.create_device()
    if device is None:
        continue  # unsupported product or transient failure
    print(f"Created {type(device).__name__}: {await device.get_label()}")
Source code in src/lifx/network/discovery/udp.py
async def create_device(self):
    """Create appropriate device instance based on product capabilities.

    Queries the device for its product ID and uses the product registry
    to instantiate the appropriate device class (Device, Light, HevLight,
    InfraredLight, MultiZoneLight, MatrixLight, or CeilingLight) based on
    the product capabilities.

    This is the single source of truth for device type detection and
    instantiation across the library.

    Returns:
        Device instance of the appropriate type

    Raises:
        TypeError: If a concrete device constructor no longer accepts the
            shared discovery arguments.
        AttributeError: If an internal device capability or metadata
            contract is broken.

    Example:
        ```python
        async for discovered in discover_devices():
            device = await discovered.create_device()
            if device is None:
                continue  # unsupported product or transient failure
            print(f"Created {type(device).__name__}: {await device.get_label()}")
        ```
    """
    # Intentional local imports preserve the documented layer direction.
    # Importing the device layer while this network module initialises
    # creates a cycle when callers reach ``lifx.devices`` before the
    # top-level package has already populated ``lifx.network``.
    from lifx.devices.base import Device
    from lifx.devices.detection import get_device_class_for_product

    try:
        # Create temporary device to query version. Address validation can
        # fail here, before a connection exists, so keep construction
        # inside the same failure boundary as capability detection.
        temp_device = Device(
            serial=self.serial,
            ip=self.ip,
            port=self.port,
            timeout=self.timeout,
            max_retries=self.max_retries,
        )
        construction_task = asyncio.current_task()
        if construction_task is not None:
            self._construction_connections[construction_task] = (
                temp_device.connection
            )

    except ValueError as error:
        _LOGGER.debug(
            {
                "class": "DiscoveredDevice",
                "method": "create_device",
                "action": "invalid_device_address",
                "serial": self.serial,
                "ip": self.ip,
                "reason": str(error),
            }
        )
        return None

    try:
        await temp_device.ensure_capabilities()
    except LifxError as error:
        _LOGGER.debug(
            {
                "class": "DiscoveredDevice",
                "method": "create_device",
                "action": "capability_detection_failed",
                "error_type": type(error).__name__,
            }
        )
        return None
    finally:
        # Always close the temporary device connection
        try:
            await temp_device.connection.close()
        finally:
            if construction_task is not None:
                self._construction_connections.pop(construction_task, None)

    if not temp_device.capabilities or not temp_device.version:
        return None

    try:
        device_class = get_device_class_for_product(
            temp_device.version.product,
            temp_device.capabilities,
        )
    except LifxUnsupportedDeviceError:
        return None

    # Keep typed-device construction outside the transient network failure
    # boundary. A missing constructor pass-through is a programming error,
    # and must fail visibly instead of silently removing that product from
    # discovery.
    device = device_class(
        serial=self.serial,
        ip=self.ip,
        port=self.port,
        timeout=self.timeout,
        max_retries=self.max_retries,
    )

    # Capability detection already fetched and derived this metadata.
    # Preserve it on the correctly typed instance so callers do not
    # immediately repeat the same network work.
    device.adopt_cached_metadata(temp_device)
    return device
__hash__
__hash__() -> int

Hash based on serial number for deduplication.

Source code in src/lifx/network/discovery/udp.py
def __hash__(self) -> int:
    """Hash based on serial number for deduplication."""
    return hash(self.serial)
__eq__
__eq__(other: object) -> bool

Equality based on serial number.

Source code in src/lifx/network/discovery/udp.py
def __eq__(self, other: object) -> bool:
    """Equality based on serial number."""
    if not isinstance(other, DiscoveredDevice):
        return False
    return self.serial == other.serial

DiscoveryResponse

Response dataclass from custom discovery broadcasts (using packets other than GetService).

DiscoveryResponse dataclass

DiscoveryResponse(
    serial: str,
    ip: str,
    port: int,
    response_time: float,
    response_payload: dict[str, Any],
)

Response from a discovery broadcast using a custom packet.

ATTRIBUTE DESCRIPTION
serial

Device serial number

TYPE: str

ip

Device IP address

TYPE: str

port

UDP source port the device responded from (addr[1]), not a device-reported service port. For GetService discovery the authoritative service port is in response_payload["port"].

TYPE: int

response_time

Response time in seconds, anchored at the first broadcast (time since discovery began). A device answering a later re-broadcast reports a proportionally larger value.

TYPE: float

response_payload

Unpacked State packet fields as key/value dict

TYPE: dict[str, Any]

mDNS discovery

The canonical low-level mDNS namespace is lifx.network.discovery.mdns. The former lifx.network.mdns package remains as a thin import-compatibility surface and contains no duplicate implementation.

discover_devices_mdns async

discover_devices_mdns(
    timeout: float = DISCOVERY_TIMEOUT,
    max_response_time: float = MAX_RESPONSE_TIME,
    idle_timeout_multiplier: float = IDLE_TIMEOUT_MULTIPLIER,
    device_timeout: float = DEFAULT_REQUEST_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
) -> AsyncGenerator[Light, None]

Discover LIFX devices via mDNS and yield device instances.

This is the high-level API that yields fully-typed device instances (Light, MatrixLight, MultiZoneLight, etc.) based on product capabilities.

Devices that are not lights (relays, buttons without color) are automatically filtered out and not yielded.

PARAMETER DESCRIPTION
timeout

Overall discovery timeout in seconds

TYPE: float DEFAULT: DISCOVERY_TIMEOUT

max_response_time

Maximum expected response time

TYPE: float DEFAULT: MAX_RESPONSE_TIME

idle_timeout_multiplier

Multiplier for idle timeout

TYPE: float DEFAULT: IDLE_TIMEOUT_MULTIPLIER

device_timeout

Request timeout for created devices

TYPE: float DEFAULT: DEFAULT_REQUEST_TIMEOUT

max_retries

Maximum retry attempts for device requests

TYPE: int DEFAULT: DEFAULT_MAX_RETRIES

YIELDS DESCRIPTION
AsyncGenerator[Light, None]

Device instances (Light, MatrixLight, etc.) as they are discovered

Example
async for device in discover_devices_mdns(timeout=10.0):
    async with device:
        label = await device.get_label()
        print(f"{type(device).__name__}: {label} at {device.ip}")
Source code in src/lifx/network/discovery/mdns/discovery.py
async def discover_devices_mdns(
    timeout: float = DISCOVERY_TIMEOUT,
    max_response_time: float = MAX_RESPONSE_TIME,
    idle_timeout_multiplier: float = IDLE_TIMEOUT_MULTIPLIER,
    device_timeout: float = DEFAULT_REQUEST_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
) -> AsyncGenerator[Light, None]:
    """Discover LIFX devices via mDNS and yield device instances.

    This is the high-level API that yields fully-typed device instances
    (Light, MatrixLight, MultiZoneLight, etc.) based on product capabilities.

    Devices that are not lights (relays, buttons without color) are automatically
    filtered out and not yielded.

    Args:
        timeout: Overall discovery timeout in seconds
        max_response_time: Maximum expected response time
        idle_timeout_multiplier: Multiplier for idle timeout
        device_timeout: Request timeout for created devices
        max_retries: Maximum retry attempts for device requests

    Yields:
        Device instances (Light, MatrixLight, etc.) as they are discovered

    Example:
        ```python
        async for device in discover_devices_mdns(timeout=10.0):
            async with device:
                label = await device.get_label()
                print(f"{type(device).__name__}: {label} at {device.ip}")
        ```
    """
    records = _discover_lifx_services(
        timeout=timeout,
        max_response_time=max_response_time,
        idle_timeout_multiplier=idle_timeout_multiplier,
    )
    async with aclosing(records):
        async for record in records:
            if not _is_lifx_service_instance(record.service_instance):
                continue
            try:
                validate_address(record.ip)
            except ValueError:
                continue

            device = _create_device_from_record(
                record,
                timeout=device_timeout,
                max_retries=max_retries,
            )

            if device is not None:
                yield device

UDP Transport

Low-level UDP transport for sending and receiving LIFX protocol messages.

UdpTransport

UdpTransport(
    ip_address: str = DEFAULT_IP_ADDRESS,
    port: int = 0,
    broadcast: bool = False,
    peer: PeerInfo | None = None,
)

UDP transport for sending and receiving LIFX packets.

This class provides a simple interface for UDP communication with LIFX devices. It uses asyncio for async I/O operations.

PARAMETER DESCRIPTION
port

Local port to bind to (0 for automatic assignment)

TYPE: int DEFAULT: 0

broadcast

Enable broadcast mode for device discovery

TYPE: bool DEFAULT: False

peer

Device this socket talks to, included in warning logs so an unreachable-peer error names the device. Held by reference, so a serial learned after open() shows up in later records. Left unset for the shared broadcast socket, which has no single peer.

TYPE: PeerInfo | None DEFAULT: None

METHOD DESCRIPTION
open

Open the UDP socket.

send

Send data to a specific address.

receive

Receive data from socket with size validation.

close

Close the UDP socket.

ATTRIBUTE DESCRIPTION
is_open

Check if socket is open.

TYPE: bool

Source code in src/lifx/network/transport.py
def __init__(
    self,
    ip_address: str = DEFAULT_IP_ADDRESS,
    port: int = 0,
    broadcast: bool = False,
    peer: PeerInfo | None = None,
) -> None:
    """Initialize UDP transport.

    Args:
        port: Local port to bind to (0 for automatic assignment)
        broadcast: Enable broadcast mode for device discovery
        peer: Device this socket talks to, included in warning logs so an
            unreachable-peer error names the device. Held by reference, so
            a serial learned after open() shows up in later records. Left
            unset for the shared broadcast socket, which has no single
            peer.
    """
    self._ip_address = ip_address
    self._port = port
    self._broadcast = broadcast
    self._peer = peer
    self._protocol: _UdpProtocol | None = None
    self._transport: DatagramTransport | None = None
    # Recorded by open() so send() can compare a destination against it
    # without asking asyncio for the socket on every datagram. None means
    # no endpoint has been built yet, which send() reports as "not open".
    self._family: socket.AddressFamily | None = None
    self._state_lock = asyncio.Lock()
    self._state_generation = 0

Attributes

is_open property
is_open: bool

Check if socket is open.

Methods:

open async
open() -> None

Open the UDP socket.

Source code in src/lifx/network/transport.py
async def open(self) -> None:
    """Open the UDP socket."""
    generation = self._state_generation
    async with self._state_lock:
        if generation != self._state_generation:
            return

        if self.is_open:
            _LOGGER.debug(
                {
                    "class": "UdpTransport",
                    "method": "open",
                    "action": "already_open",
                    "ip_address": self._ip_address,
                    "port": self._port,
                }
            )
            return

        datagram_transport: DatagramTransport | None = None
        raw_socket: socket.socket | None = None
        try:
            loop = asyncio.get_running_loop()

            _LOGGER.debug(
                {
                    "class": "UdpTransport",
                    "method": "open",
                    "action": "opening_socket",
                    "ip_address": self._ip_address,
                    "port": self._port,
                    "broadcast": self._broadcast,
                }
            )

            # Create protocol
            protocol = _UdpProtocol(
                on_endpoint_lost=self._endpoint_lost, peer=self._peer
            )

            # Create datagram endpoint. The socket family follows the local
            # bind address, derived by the one shared rule in
            # lifx.network.address, which is what lets this transport reach
            # Thread devices that have no IPv4 address.
            family = family_for(self._ip_address)
            bind_address = sockaddr_for(
                (self._ip_address, self._port), require_routable=False
            )
            if family is socket.AF_INET6:
                raw_socket = _socket_factory(socket.AF_INET6, socket.SOCK_DGRAM)
                raw_socket.setsockopt(
                    socket.IPPROTO_IPV6,
                    socket.IPV6_V6ONLY,
                    1,
                )
                if _SO_REUSEPORT is not None:
                    raw_socket.setsockopt(
                        socket.SOL_SOCKET,
                        _SO_REUSEPORT,
                        1,
                    )
                raw_socket.bind(bind_address)
                raw_socket.setblocking(False)
                datagram_transport, _ = await loop.create_datagram_endpoint(
                    lambda: protocol,
                    sock=raw_socket,
                )
                raw_socket = None  # asyncio owns the descriptor now
            else:
                assert len(bind_address) == 2
                datagram_transport, _ = await loop.create_datagram_endpoint(
                    lambda: protocol,
                    local_addr=bind_address,
                    reuse_port=_SO_REUSEPORT is not None,
                    family=family,
                )

            # Get actual port assigned
            actual_port = datagram_transport.get_extra_info("sockname")[1]
            _LOGGER.debug(
                {
                    "class": "UdpTransport",
                    "method": "open",
                    "action": "socket_opened",
                    "assigned_port": actual_port,
                    "broadcast": self._broadcast,
                }
            )

            # Enable broadcast if requested
            if self._broadcast:
                sock = datagram_transport.get_extra_info("socket")
                if sock:
                    sock.setsockopt(
                        socket.SOL_SOCKET,
                        socket.SO_BROADCAST,
                        1,
                    )
                    _LOGGER.debug(
                        {
                            "class": "UdpTransport",
                            "method": "open",
                            "action": "broadcast_enabled",
                        }
                    )

            if generation != self._state_generation:
                datagram_transport.close()
                return

            self._protocol = protocol
            self._transport = datagram_transport
            self._family = family

        except BaseException as e:
            if datagram_transport is not None:
                datagram_transport.close()
            if raw_socket is not None:
                raw_socket.close()
            self._transport = None
            self._protocol = None
            self._family = None

            _LOGGER.debug(
                self._log(
                    method="open",
                    action="failed",
                    bind_address=self._ip_address,
                    bind_port=self._port,
                    reason=str(e),
                )
            )
            if isinstance(e, (OSError, ValueError)):
                raise LifxNetworkError(f"Failed to open UDP socket: {e}") from e
            raise
send async
send(data: bytes, address: SocketAddress) -> None

Send data to a specific address.

The destination's address family is checked against the socket's before the datagram is handed to asyncio. A mismatch is a permanent configuration error, but the socket reports it as a gaierror, an OSError subclass that :meth:_UdpProtocol.error_received deliberately swallows along with every genuine peer error. Without this check an unreachable-by-construction address is indistinguishable from a sleeping device: the caller waits out the full retry schedule and is told only that it timed out. Checking first costs one parse and names the actual problem.

This is a pre-send guard and nothing more. The routing of errors that arrive from the network is untouched: EHOSTUNREACH, EHOSTDOWN and ENETUNREACH still reach error_received and still leave the endpoint alive.

PARAMETER DESCRIPTION
data

Bytes to send

TYPE: bytes

address

Tuple of (host, port)

TYPE: SocketAddress

RAISES DESCRIPTION
LifxNetworkError

If the socket is not open, the destination's address family does not match the socket's, an IPv6 zone identifier cannot be resolved, or the send fails.

Source code in src/lifx/network/transport.py
async def send(self, data: bytes, address: SocketAddress) -> None:
    """Send data to a specific address.

    The destination's address family is checked against the socket's
    before the datagram is handed to asyncio. A mismatch is a permanent
    configuration error, but the socket reports it as a ``gaierror``,
    an ``OSError`` subclass that :meth:`_UdpProtocol.error_received`
    deliberately swallows along with every genuine peer error. Without
    this check an unreachable-by-construction address is
    indistinguishable from a sleeping device: the caller waits out the
    full retry schedule and is told only that it timed out. Checking
    first costs one parse and names the actual problem.

    This is a pre-send guard and nothing more. The routing of errors
    that arrive from the network is untouched: EHOSTUNREACH, EHOSTDOWN
    and ENETUNREACH still reach ``error_received`` and still leave the
    endpoint alive.

    Args:
        data: Bytes to send
        address: Tuple of (host, port)

    Raises:
        LifxNetworkError: If the socket is not open, the destination's
            address family does not match the socket's, an IPv6 zone
            identifier cannot be resolved, or the send fails.
    """
    if self._transport is None or self._protocol is None or self._family is None:
        raise LifxNetworkError("Socket not open")

    try:
        send_address = sockaddr_for(address)
    except ValueError as error:
        _LOGGER.debug(
            self._log(
                method="send",
                action="invalid_destination",
                destination_ip=address[0],
                destination_port=address[1],
                reason=str(error),
            )
        )
        raise LifxNetworkError(
            f"Invalid destination {address[0]!r}: {error}"
        ) from error

    destination_family = family_for_sockaddr(send_address)
    if destination_family is not self._family:
        _LOGGER.debug(
            self._log(
                method="send",
                action="family_mismatch",
                destination_ip=address[0],
                destination_port=address[1],
                destination_family=destination_family.name,
                socket_family=self._family.name,
            )
        )
        raise LifxNetworkError(
            f"Destination {address[0]} requires {destination_family.name} "
            f"but the socket family is {self._family.name}"
        )

    try:
        self._transport.sendto(data, send_address)
    except OSError as e:
        _LOGGER.debug(
            self._log(
                method="send",
                action="failed",
                destination_ip=address[0],
                destination_port=address[1],
                packet_size=len(data),
                reason=str(e),
            )
        )
        raise LifxNetworkError(f"Failed to send data: {e}") from e
receive async
receive(timeout: float = 2.0) -> tuple[bytes, SocketAddress]

Receive data from socket with size validation.

PARAMETER DESCRIPTION
timeout

Timeout in seconds

TYPE: float DEFAULT: 2.0

RETURNS DESCRIPTION
tuple[bytes, SocketAddress]

Tuple of (data, address) where address is (host, port)

RAISES DESCRIPTION
LifxTimeoutError

If no data received within timeout

LifxNetworkError

If socket is not open or receive fails

LifxProtocolError

If packet size is invalid

Source code in src/lifx/network/transport.py
async def receive(self, timeout: float = 2.0) -> tuple[bytes, SocketAddress]:
    """Receive data from socket with size validation.

    Args:
        timeout: Timeout in seconds

    Returns:
        Tuple of (data, address) where address is (host, port)

    Raises:
        LifxTimeoutError: If no data received within timeout
        LifxNetworkError: If socket is not open or receive fails
        LifxProtocolError: If packet size is invalid
    """
    if self._protocol is None:
        raise LifxNetworkError("Socket not open")

    try:
        data, addr = await asyncio.wait_for(
            self._protocol.queue.get(), timeout=timeout
        )
    except TIMEOUT_ERRORS as e:
        raise LifxTimeoutError(f"No data received within {timeout}s") from e
    except OSError as e:
        _LOGGER.error(self._log(method="receive", action="failed", reason=str(e)))
        raise LifxNetworkError(f"Failed to receive data: {e}") from e

    # Validate packet size
    if len(data) > MAX_PACKET_SIZE:
        _LOGGER.error(
            self._log(
                method="receive",
                action="packet_too_large",
                sender_ip=addr[0],
                sender_port=addr[1],
                packet_size=len(data),
                max_size=MAX_PACKET_SIZE,
            )
        )
        raise LifxProtocolError(
            f"Packet too big: {len(data)} bytes > {MAX_PACKET_SIZE} bytes"
        )

    if len(data) < MIN_PACKET_SIZE:
        _LOGGER.error(
            self._log(
                method="receive",
                action="packet_too_small",
                sender_ip=addr[0],
                sender_port=addr[1],
                packet_size=len(data),
                min_size=MIN_PACKET_SIZE,
            )
        )
        raise LifxProtocolError(
            f"Packet too small: {len(data)} bytes < {MIN_PACKET_SIZE} bytes"
        )

    return data, addr
close async
close() -> None

Close the UDP socket.

Source code in src/lifx/network/transport.py
async def close(self) -> None:
    """Close the UDP socket."""
    self._close_immediately()

Examples

Device Discovery

from lifx.network.discovery import discover_devices


async def main():
    # Discover all devices on the network
    async for device in discover_devices():
        print(f"Found: {device.serial} at {device.ip}")
        print(f"  Port: {device.port}")

Concurrency

Requests on a Single Connection

Each DeviceConnection correlates responses to their requests — a background receiver routes each reply to the request that sent it, so requests on one connection never mix:

import asyncio
from lifx.network.connection import DeviceConnection
from lifx.protocol.packets import Light, Device


async def main():
    conn = DeviceConnection(serial="d073d5123456", ip="192.168.1.100")

    # Requests on the same connection (responses correlated per request)
    state = await conn.request(Light.GetColor())
    power = await conn.request(Light.GetPower())
    label = await conn.request(Device.GetLabel())

    # Connection automatically closes when done
    await conn.close()

Concurrent Requests on Different Devices

import asyncio
from lifx.network.connection import DeviceConnection


async def main():
    conn1 = DeviceConnection(serial="d073d5000001", ip="192.168.1.100")
    conn2 = DeviceConnection(serial="d073d5000002", ip="192.168.1.101")

    # Fully parallel - different UDP sockets
    result1, result2 = await asyncio.gather(
        conn1.request(Light.GetColor()),
        conn2.request(Light.GetColor())
    )

    # Clean up connections
    await conn1.close()
    await conn2.close()

Connection Management

DeviceConnection

DeviceConnection(
    serial: str,
    ip: str,
    port: int = LIFX_UDP_PORT,
    max_retries: int = DEFAULT_MAX_RETRIES,
    timeout: float = DEFAULT_REQUEST_TIMEOUT,
)

Connection to a LIFX device.

This class manages the UDP transport and request/response lifecycle for a single device. Connections are opened lazily on first request and remain open until explicitly closed.

Features:

  • Lazy connection opening (no context manager required)
  • Async generator-based request/response streaming
  • Automatic retransmits on an escalating schedule within each request's timeout, listening for a reply throughout
  • Response correlation: a background receiver routes each reply to its request, so concurrent requests never mix
  • Automatic sequence number management
Example
conn = DeviceConnection(serial="d073d5123456", ip="192.168.1.100")

# Connection opens automatically on first request
state = await conn.request(packets.Light.GetColor())
# state.label is already decoded to string
# state.color is LightHsbk instance

# Optionally close when done
await conn.close()

With context manager (recommended for cleanup):

async with DeviceConnection(...) as conn:
    state = await conn.request(packets.Light.GetColor())
# Connection automatically closed on exit

This is lightweight - doesn't actually create a connection. Connection is opened lazily on first request.

PARAMETER DESCRIPTION
serial

Device serial number as 12-digit hex string (e.g., 'd073d5123456')

TYPE: str

ip

Device IP address

TYPE: str

port

Device UDP port (default LIFX_UDP_PORT)

TYPE: int DEFAULT: LIFX_UDP_PORT

max_retries

Maximum number of retransmits within the timeout (default: 8). Total transmissions are at most max_retries + 1; after the cap is reached the request keeps listening for a reply until the timeout expires instead of failing early. Whichever of the retransmit cap and the timeout is reached first wins.

TYPE: int DEFAULT: DEFAULT_MAX_RETRIES

timeout

Default timeout for requests in seconds (default: 16.0). The timeout is an overall limit on the whole request: all waiting -- transmissions, retransmit gaps, and the final listen window -- counts against it, so a request can never take materially longer than the timeout it was given.

TYPE: float DEFAULT: DEFAULT_REQUEST_TIMEOUT

METHOD DESCRIPTION
__aenter__

Enter async context manager.

__aexit__

Exit async context manager and close connection.

open

Open connection to device.

close

Close connection to device.

send_packet

Send a packet to the device.

receive_packet

Receive a packet from the device.

request_stream

Send request and yield unpacked responses.

request

Send request and get single response (convenience wrapper).

ATTRIBUTE DESCRIPTION
thread_connection

The device's own report of whether replies travel over Thread.

TYPE: bool | None

serial

Serial of the device this connection talks to.

TYPE: str

is_open

Check if connection is open.

TYPE: bool

Source code in src/lifx/network/connection.py
def __init__(
    self,
    serial: str,
    ip: str,
    port: int = LIFX_UDP_PORT,
    max_retries: int = DEFAULT_MAX_RETRIES,
    timeout: float = DEFAULT_REQUEST_TIMEOUT,
) -> None:
    """Initialize device connection.

    This is lightweight - doesn't actually create a connection.
    Connection is opened lazily on first request.

    Args:
        serial: Device serial number as 12-digit hex string (e.g., 'd073d5123456')
        ip: Device IP address
        port: Device UDP port (default LIFX_UDP_PORT)
        max_retries: Maximum number of retransmits within the timeout
            (default: 8). Total transmissions are at most
            max_retries + 1; after the cap is reached the request keeps
            listening for a reply until the timeout expires instead of
            failing early. Whichever of the retransmit cap and the
            timeout is reached first wins.
        timeout: Default timeout for requests in seconds (default: 16.0).
            The timeout is an overall limit on the whole request: all
            waiting -- transmissions, retransmit gaps, and the final
            listen window -- counts against it, so a request can never
            take materially longer than the timeout it was given.
    """
    # Normalise the serial so separator-formatted input (a form
    # Serial.from_string() accepts) logs and compares identically to the
    # 12-digit form a Device instance stores for the same hardware. Parsed
    # once: the protocol bytes below come from the same object rather than
    # a second from_string() call, so the two forms cannot drift apart.
    serial_obj = Serial.from_string(serial)
    # Correlation identity. Read it through the `serial` property unless
    # you specifically need the value responses are being keyed on right
    # now -- a serial learned mid-flight is visible on the property before
    # this field adopts it.
    self._serial = serial_obj.to_string()
    self.ip = ip
    self.port = port
    self._send_address: SocketAddress | None = None
    self.max_retries = max_retries
    self.timeout = timeout

    self._transport: UdpTransport | None = None
    self._opening_transport: UdpTransport | None = None
    self._is_open = False
    # Flag to prevent concurrent open() calls. Deliberately a plain bool
    # with a poll loop rather than asyncio.Lock: a Lock binds to the event
    # loop that first awaits it, which breaks connections that are closed
    # and reopened under a different loop (e.g. one connection shared
    # across per-test event loops).
    self._is_opening = False
    self._is_closing = False
    # A close increments this synchronously before its first await. Openers
    # capture the generation at entry and refuse to publish transport state
    # if any close began after they were requested. An integer is deliberately
    # loop-agnostic so a closed connection can reopen under another loop.
    self._state_generation = 0

    # Identity handed to the transport for its warning logs. Mutated in
    # place when discovery learns the real serial, so records emitted
    # after that name the device rather than the placeholder.
    self._peer = PeerInfo(serial=self._serial, ip=self.ip, port=self.port)

    # Pre-compute serial bytes for fast comparison in background receiver
    self._is_discovery = self._serial == "000000000000"
    if not self._is_discovery:
        self._target_bytes: bytes | None = serial_obj.to_protocol()
    else:
        self._target_bytes = None

    # Serial learned from a reply on a connection opened without one.
    # Reported by the `serial` property immediately; adopted as the
    # correlation identity only at the start of a later request, because
    # switching it while a request is registered under the placeholder
    # would orphan that request's keys (see _adopt_learned_serial).
    self._learned_serial: str | None = None

    # Pre-compute target bytes for send_packet() to avoid
    # re-parsing on every send
    if self._target_bytes is not None:
        self._send_target: bytes = self._target_bytes
    else:
        self._send_target: bytes = b"\x00" * 8

    # Device-reported transport, learned from correlated responses
    self._thread_connection: bool | None = None

    # Background receiver task infrastructure
    # Key: (source, sequence, serial) → Queue of (header, payload) tuples
    self._pending_requests: dict[
        tuple[int, int, str],
        asyncio.Queue[tuple[LifxHeader, bytes] | _ConnectionClosed],
    ] = {}
    self._receiver_task: asyncio.Task[None] | None = None
    self._receiver_shutdown: asyncio.Event | None = None

Attributes

thread_connection property
thread_connection: bool | None

The device's own report of whether replies travel over Thread.

None until a correlated response has been observed. Set from LifxHeader.thread_connection (frame address byte 22, bit 3) only after source/sequence/serial validation succeeds, so stray traffic on the shared socket can never assert a transport for this device.

A device's radio operates in either WiFi or Thread mode and cannot change without a firmware crossgrade, so this value is invariant for a given device once observed.

serial property
serial: str

Serial of the device this connection talks to.

A connection opened without a serial (000000000000) reports the real one as soon as a reply carries it, which is what callers such as Device.from_ip() read straight after their first request. That is deliberately sooner than the connection adopts it as its correlation identity: see :meth:_adopt_learned_serial.

is_open property
is_open: bool

Check if connection is open.

Methods:

__aenter__ async
__aenter__() -> Self

Enter async context manager.

Source code in src/lifx/network/connection.py
async def __aenter__(self) -> Self:
    """Enter async context manager."""
    # Don't open connection here - it will open lazily on first request
    return self
__aexit__ async
__aexit__(
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: object,
) -> None

Exit async context manager and close connection.

Source code in src/lifx/network/connection.py
async def __aexit__(
    self,
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: object,
) -> None:
    """Exit async context manager and close connection."""
    await self.close()
open async
open() -> None

Open connection to device.

Opens the UDP transport for sending and receiving packets. Called automatically on first request if not already open.

Source code in src/lifx/network/connection.py
async def open(self) -> None:
    """Open connection to device.

    Opens the UDP transport for sending and receiving packets.
    Called automatically on first request if not already open.
    """
    generation = self._state_generation
    if self._is_open:
        return

    # Claim the opener role without an await between the check and write.
    # A waiter retries the decision after the current opener finishes: it
    # must never return success solely because a failed opener cleared the
    # flag. The poll remains loop-agnostic for connections reopened under
    # a different event loop.
    while True:
        if generation != self._state_generation:
            return
        if self._is_open:
            return
        if self._is_closing:
            while self._is_closing:
                await asyncio.sleep(0.001)
            continue
        if not self._is_opening:
            self._is_opening = True
            break
        while self._is_opening:
            await asyncio.sleep(0.001)

    transport: UdpTransport | None = None
    try:
        # Double-check after setting flag
        if self._is_open:  # pragma: no cover
            return

        # Open transport, binding the wildcard that matches the device
        # address family: IPv6 for Thread devices, IPv4 otherwise. The
        # shared rule owns the choice, so this method makes no family
        # test of its own.
        try:
            local_ip = wildcard_for(self.ip)
            send_address = sockaddr_for((self.ip, self.port))
        except ValueError as error:
            raise LifxNetworkError(
                f"Invalid destination {self.ip!r}: {error}"
            ) from error
        transport = UdpTransport(
            ip_address=local_ip, port=0, broadcast=False, peer=self._peer
        )
        self._opening_transport = transport
        await transport.open()

        if generation != self._state_generation:
            # close() synchronously takes ownership from
            # _opening_transport before its first await, so it also owns
            # closing this invalidated endpoint.
            return

        self._opening_transport = None
        self._transport = transport
        self._send_address = send_address
        self._receiver_shutdown = asyncio.Event()
        self._is_open = True

        # Start background receiver task
        self._receiver_task = asyncio.create_task(self._background_receiver())

        _LOGGER.debug(
            {
                "class": "DeviceConnection",
                "method": "open",
                "serial": self.serial,
                "ip": self.ip,
                "port": self.port,
            }
        )
    except BaseException:
        failed_transport = (
            transport if self._opening_transport is transport else None
        )
        self._opening_transport = None
        transport = None
        self._transport = None
        self._receiver_shutdown = None
        if failed_transport is not None:
            try:
                await failed_transport.close()
            except BaseException as cleanup_error:
                _LOGGER.debug(
                    {
                        "class": "DeviceConnection",
                        "method": "open",
                        "action": "failed_transport_cleanup_failed",
                        "serial": self.serial,
                        "ip": self.ip,
                        "port": self.port,
                        "error": str(cleanup_error),
                    }
                )
        raise
    finally:
        self._is_opening = False
close async
close() -> None

Close connection to device.

Source code in src/lifx/network/connection.py
async def close(self) -> None:
    """Close connection to device."""
    self._state_generation += 1
    self._invalidate_pending_requests()
    if not self._is_open:
        opening_transport, self._opening_transport = (
            self._opening_transport,
            None,
        )
        if opening_transport is not None:
            await opening_transport.close()
        return

    self._is_closing = True
    self._is_open = False
    transport = self._transport
    assert transport is not None  # _is_open implies a published transport
    receiver_task = self._receiver_task
    receiver_shutdown = self._receiver_shutdown
    assert receiver_shutdown is not None  # _is_open implies a shutdown signal
    cancelled: asyncio.CancelledError | None = None

    receiver_shutdown.set()

    async def _finish_cleanup() -> None:
        """Finish active-session teardown independently of caller cancellation."""
        try:
            if receiver_task:
                try:
                    await asyncio.wait_for(
                        receiver_task, timeout=_RECEIVER_SHUTDOWN_TIMEOUT
                    )
                except TIMEOUT_ERRORS:
                    receiver_task.cancel()
                    try:
                        await receiver_task
                    except asyncio.CancelledError:
                        pass
        finally:
            await transport.close()

    cleanup_task = asyncio.create_task(_finish_cleanup())
    try:
        while not cleanup_task.done():
            try:
                await asyncio.shield(cleanup_task)
            except asyncio.CancelledError as error:
                if cancelled is None:
                    cancelled = error

        cleanup_task.result()

        _LOGGER.debug(
            {
                "class": "DeviceConnection",
                "method": "close",
                "serial": self.serial,
                "ip": self.ip,
            }
        )
    finally:
        self._transport = None
        self._receiver_task = None
        self._receiver_shutdown = None
        self._send_address = None
        self._is_closing = False

    if cancelled is not None:
        raise cancelled
send_packet async
send_packet(
    packet: Any,
    source: int | None = None,
    sequence: int = 0,
    ack_required: bool = False,
    res_required: bool = False,
) -> None

Send a packet to the device.

PARAMETER DESCRIPTION
packet

Packet dataclass instance

TYPE: Any

source

Client source identifier (optional, allocated if None)

TYPE: int | None DEFAULT: None

sequence

Sequence number (default: 0)

TYPE: int DEFAULT: 0

ack_required

Request acknowledgement

TYPE: bool DEFAULT: False

res_required

Request response

TYPE: bool DEFAULT: False

RAISES DESCRIPTION
ConnectionError

If connection is not open or send fails

Source code in src/lifx/network/connection.py
async def send_packet(
    self,
    packet: Any,
    source: int | None = None,
    sequence: int = 0,
    ack_required: bool = False,
    res_required: bool = False,
) -> None:
    """Send a packet to the device.

    Args:
        packet: Packet dataclass instance
        source: Client source identifier (optional, allocated if None)
        sequence: Sequence number (default: 0)
        ack_required: Request acknowledgement
        res_required: Request response

    Raises:
        ConnectionError: If connection is not open or send fails
    """
    if not self._is_open or self._transport is None or self._send_address is None:
        raise LifxConnectionError("Connection not open")

    # Allocate source if not provided
    if source is None:
        source = allocate_source()

    message = create_message(
        packet=packet,
        source=source,
        sequence=sequence,
        target=self._send_target,
        ack_required=ack_required,
        res_required=res_required,
    )

    # Send to device
    await self._transport.send(message, self._send_address)
receive_packet async
receive_packet(timeout: float = 0.5) -> tuple[LifxHeader, bytes]

Receive a packet from the device.

Note

This method does not validate the source IP address. Validation is instead performed using the LIFX protocol's built-in target field (serial number) and sequence number matching in request_stream() and request_ack_stream(). This approach is more reliable in complex network configurations (NAT, multiple interfaces, bridges, etc.) while maintaining security through proper protocol-level validation.

PARAMETER DESCRIPTION
timeout

Timeout in seconds

TYPE: float DEFAULT: 0.5

RETURNS DESCRIPTION
tuple[LifxHeader, bytes]

Tuple of (header, payload)

RAISES DESCRIPTION
ConnectionError

If connection is not open

TimeoutError

If no response within timeout

Source code in src/lifx/network/connection.py
async def receive_packet(self, timeout: float = 0.5) -> tuple[LifxHeader, bytes]:
    """Receive a packet from the device.

    Note:
        This method does not validate the source IP address. Validation is instead
        performed using the LIFX protocol's built-in target field (serial number)
        and sequence number matching in request_stream() and request_ack_stream().
        This approach is more reliable in complex network configurations (NAT,
        multiple interfaces, bridges, etc.) while maintaining security through
        proper protocol-level validation.

    Args:
        timeout: Timeout in seconds

    Returns:
        Tuple of (header, payload)

    Raises:
        ConnectionError: If connection is not open
        TimeoutError: If no response within timeout
    """
    if not self._is_open or self._transport is None:
        raise LifxConnectionError("Connection not open")

    # Receive message - source address not validated here
    # Validation occurs via target field and sequence number matching
    data, _addr = await self._transport.receive(timeout=timeout)

    # Parse and return message
    return parse_message(data)
request_stream async
request_stream(
    packet: Any, timeout: float | None = None
) -> AsyncGenerator[Any, None]

Send request and yield unpacked responses.

This is an async generator that handles the complete request/response cycle including packet type detection, response unpacking, and label decoding. Connection is opened automatically if not already open.

Single response (most common): async for response in conn.request_stream(GetLabel()): process(response) break # Exit immediately

Multiple responses

async for state in conn.request_stream(GetColorZones()): process(state) # Continues until timeout

PARAMETER DESCRIPTION
packet

Packet instance to send

TYPE: Any

timeout

Request timeout in seconds

TYPE: float | None DEFAULT: None

YIELDS DESCRIPTION
AsyncGenerator[Any, None]

Unpacked response packet instances (including StateUnhandled if device

AsyncGenerator[Any, None]

doesn't support the command)

AsyncGenerator[Any, None]

For SET packets: yields True (acknowledgement) or False (StateUnhandled)

RAISES DESCRIPTION
LifxTimeoutError

If request times out

LifxProtocolError

If response invalid

LifxConnectionError

If connection fails

Example
# GET request yields unpacked packets
async for state in conn.request_stream(packets.Light.GetColor()):
    color = HSBK.from_protocol(state.color)
    label = state.label  # Already decoded to string
    break

# SET request yields True (acknowledgement) or False (StateUnhandled)
async for result in conn.request_stream(
    packets.Light.SetColor(color=hsbk, duration=1000)
):
    if result:
        # Acknowledgement received
        pass
    else:
        # Device doesn't support this command
        pass
    break

# Multi-response GET - stream all responses
async for state in conn.request_stream(
    packets.MultiZone.GetExtendedColorZones()
):
    # Process each zone state
    pass
Source code in src/lifx/network/connection.py
async def request_stream(
    self,
    packet: Any,
    timeout: float | None = None,
) -> AsyncGenerator[Any, None]:
    """Send request and yield unpacked responses.

    This is an async generator that handles the complete request/response
    cycle including packet type detection, response unpacking, and label
    decoding. Connection is opened automatically if not already open.

    Single response (most common):
        async for response in conn.request_stream(GetLabel()):
            process(response)
            break  # Exit immediately

    Multiple responses:
        async for state in conn.request_stream(GetColorZones()):
            process(state)
            # Continues until timeout

    Args:
        packet: Packet instance to send
        timeout: Request timeout in seconds

    Yields:
        Unpacked response packet instances (including StateUnhandled if device
        doesn't support the command)
        For SET packets: yields True (acknowledgement) or False (StateUnhandled)

    Raises:
        LifxTimeoutError: If request times out
        LifxProtocolError: If response invalid
        LifxConnectionError: If connection fails

    Example:
        ```python
        # GET request yields unpacked packets
        async for state in conn.request_stream(packets.Light.GetColor()):
            color = HSBK.from_protocol(state.color)
            label = state.label  # Already decoded to string
            break

        # SET request yields True (acknowledgement) or False (StateUnhandled)
        async for result in conn.request_stream(
            packets.Light.SetColor(color=hsbk, duration=1000)
        ):
            if result:
                # Acknowledgement received
                pass
            else:
                # Device doesn't support this command
                pass
            break

        # Multi-response GET - stream all responses
        async for state in conn.request_stream(
            packets.MultiZone.GetExtendedColorZones()
        ):
            # Process each zone state
            pass
        ```
    """
    # Ensure connection is open (lazy opening)
    await self._ensure_open()

    if timeout is None:
        timeout = self.timeout

    # Get packet metadata
    packet_kind = getattr(packet, "_packet_kind", "OTHER")

    if packet_kind == "GET":
        # Stream responses and unpack each
        async for header, payload in self._request_stream_impl(
            packet, timeout=timeout
        ):
            packet_class = get_packet_class(header.pkt_type)
            if packet_class is None:
                raise LifxProtocolError(
                    f"Unknown packet type {header.pkt_type} in response"
                )

            # Note: the serial of a connection opened without one is
            # learned in _transmit_and_listen (every request path, not
            # just GET) and adopted once no request is in flight.

            # Unpack (labels are automatically decoded by Packet.unpack())
            response_packet = packet_class.unpack(payload)

            # Log the request/reply cycle (as_dict is costly — skip
            # building it unless DEBUG logging is enabled)
            if _LOGGER.isEnabledFor(logging.DEBUG):
                _LOGGER.debug(
                    {
                        "class": "DeviceConnection",
                        "method": "request_stream",
                        "request": {
                            "packet": type(packet).__name__,
                            "values": packet.as_dict,
                        },
                        "reply": {
                            "packet": type(response_packet).__name__,
                            "values": response_packet.as_dict,
                        },
                        "serial": self.serial,
                        "ip": self.ip,
                    }
                )

            yield response_packet

    elif packet_kind == "SET":
        # Request acknowledgement
        async for ack_result in self._request_ack_stream_impl(
            packet, timeout=timeout
        ):
            # Log the request/ack cycle
            if _LOGGER.isEnabledFor(logging.DEBUG):
                _LOGGER.debug(
                    {
                        "class": "DeviceConnection",
                        "method": "request_stream",
                        "request": {
                            "packet": type(packet).__name__,
                            "values": packet.as_dict,
                        },
                        "reply": {
                            "packet": "Acknowledgement"
                            if ack_result
                            else "StateUnhandled",
                            "values": {},
                        },
                        "serial": self.serial,
                        "ip": self.ip,
                    }
                )

            yield ack_result
            return

    else:
        # Handle special cases
        if hasattr(packet, "PKT_TYPE"):
            pkt_type = packet.PKT_TYPE
            # EchoRequest/EchoResponse (58/59)
            if pkt_type == 58:  # EchoRequest
                async for header, payload in self._request_stream_impl(
                    packet, timeout=timeout
                ):
                    response_packet = Device.EchoResponse.unpack(payload)

                    # Log the request/reply cycle
                    if _LOGGER.isEnabledFor(logging.DEBUG):
                        _LOGGER.debug(
                            {
                                "class": "DeviceConnection",
                                "method": "request_stream",
                                "request": {
                                    "packet": type(packet).__name__,
                                    "values": packet.as_dict,
                                },
                                "reply": {
                                    "packet": type(response_packet).__name__,
                                    "values": response_packet.as_dict,
                                },
                                "serial": self.serial,
                                "ip": self.ip,
                            }
                        )

                    yield response_packet
                    return
            else:
                raise LifxProtocolError(
                    f"Cannot auto-handle packet kind: {packet_kind}"
                )
        else:
            raise LifxProtocolError(
                f"Packet missing PKT_TYPE: {type(packet).__name__}"
            )
request async
request(packet: Any, timeout: float | None = None) -> Any

Send request and get single response (convenience wrapper).

This is a convenience method that returns the first response from request_stream(). It's equivalent to: await anext(conn.request_stream(packet))

Most device operations use this method since they expect a single response. Connection is opened automatically if not already open.

PARAMETER DESCRIPTION
packet

Packet instance to send

TYPE: Any

timeout

Request timeout in seconds

TYPE: float | None DEFAULT: None

RETURNS DESCRIPTION
Any

Single unpacked response packet (including StateUnhandled if device

Any

doesn't support the command)

Any

For SET packets: True (acknowledgement) or False (StateUnhandled)

RAISES DESCRIPTION
LifxTimeoutError

If no response within timeout

LifxProtocolError

If response invalid

LifxConnectionError

If connection fails

Example
# GET request returns unpacked packet
state = await conn.request(packets.Light.GetColor())
color = HSBK.from_protocol(state.color)
label = state.label  # Already decoded to string

# SET request returns True or False
success = await conn.request(
    packets.Light.SetColor(color=hsbk, duration=1000)
)
if not success:
    # Device doesn't support this command (returned StateUnhandled)
    pass
Source code in src/lifx/network/connection.py
async def request(self, packet: Any, timeout: float | None = None) -> Any:
    """Send request and get single response (convenience wrapper).

    This is a convenience method that returns the first response from
    request_stream(). It's equivalent to:
        await anext(conn.request_stream(packet))

    Most device operations use this method since they expect a single response.
    Connection is opened automatically if not already open.

    Args:
        packet: Packet instance to send
        timeout: Request timeout in seconds

    Returns:
        Single unpacked response packet (including StateUnhandled if device
        doesn't support the command)
        For SET packets: True (acknowledgement) or False (StateUnhandled)

    Raises:
        LifxTimeoutError: If no response within timeout
        LifxProtocolError: If response invalid
        LifxConnectionError: If connection fails

    Example:
        ```python
        # GET request returns unpacked packet
        state = await conn.request(packets.Light.GetColor())
        color = HSBK.from_protocol(state.color)
        label = state.label  # Already decoded to string

        # SET request returns True or False
        success = await conn.request(
            packets.Light.SetColor(color=hsbk, duration=1000)
        )
        if not success:
            # Device doesn't support this command (returned StateUnhandled)
            pass
        ```
    """
    async for response in self.request_stream(packet, timeout):
        return response
    raise LifxTimeoutError(f"No response from {self.ip}")

Performance Considerations

Connection Lifecycle

  • Connections open lazily on first request
  • Each device owns its own connection (no shared pool)
  • Connections close explicitly via close() or context manager exit
  • Low memory overhead (one UDP socket per device)
  • A connection whose UDP endpoint dies is rebuilt on the next request. Only genuine endpoint death triggers this: a device that is asleep, slow or off the network produces per-datagram errors and ordinary request timeouts, which leave the socket alone.

Response Handling

  • Responses matched by sequence number
  • Async generator-based streaming for efficient multi-response protocols
  • Immediate exit for single-response requests (no wasted timeout)
  • Retry logic with exponential backoff and jitter

Rate Limiting

The library intentionally does not implement rate limiting to keep the core library simple. Applications should implement their own rate limiting if needed. According to the LIFX protocol specification, devices can handle approximately 20 messages per second.