lifx-async 5.1.0__py3-none-any.whl → 5.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lifx/animation/packets.py +24 -12
- lifx/devices/base.py +80 -33
- lifx/devices/light.py +45 -22
- lifx/network/connection.py +46 -13
- lifx/protocol/base.py +48 -28
- lifx/protocol/header.py +3 -4
- {lifx_async-5.1.0.dist-info → lifx_async-5.1.1.dist-info}/METADATA +1 -1
- {lifx_async-5.1.0.dist-info → lifx_async-5.1.1.dist-info}/RECORD +10 -10
- {lifx_async-5.1.0.dist-info → lifx_async-5.1.1.dist-info}/WHEEL +0 -0
- {lifx_async-5.1.0.dist-info → lifx_async-5.1.1.dist-info}/licenses/LICENSE +0 -0
lifx/animation/packets.py
CHANGED
|
@@ -51,7 +51,7 @@ ACK_REQUIRED = 0
|
|
|
51
51
|
RES_REQUIRED = 0
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
@dataclass
|
|
54
|
+
@dataclass(slots=True)
|
|
55
55
|
class PacketTemplate:
|
|
56
56
|
"""Prebaked packet template for zero-allocation animation.
|
|
57
57
|
|
|
@@ -63,12 +63,14 @@ class PacketTemplate:
|
|
|
63
63
|
color_offset: Byte offset where color data starts
|
|
64
64
|
color_count: Number of HSBK colors in this packet
|
|
65
65
|
hsbk_start: Starting index in the input HSBK array
|
|
66
|
+
fmt: Pre-computed struct format string for bulk color packing
|
|
66
67
|
"""
|
|
67
68
|
|
|
68
69
|
data: bytearray
|
|
69
70
|
color_offset: int
|
|
70
71
|
color_count: int
|
|
71
72
|
hsbk_start: int
|
|
73
|
+
fmt: str
|
|
72
74
|
|
|
73
75
|
|
|
74
76
|
def _build_header(
|
|
@@ -103,9 +105,8 @@ def _build_header(
|
|
|
103
105
|
# Frame Address (16 bytes)
|
|
104
106
|
# target (8 bytes) + reserved (6 bytes) + flags (1 byte) + sequence (1 byte)
|
|
105
107
|
target_padded = target + b"\x00\x00" if len(target) == 6 else target
|
|
106
|
-
target_int = int.from_bytes(target_padded, byteorder="little")
|
|
107
108
|
flags = (RES_REQUIRED & 0b1) | ((ACK_REQUIRED & 0b1) << 1)
|
|
108
|
-
struct.pack_into("<
|
|
109
|
+
struct.pack_into("<8s6sBB", header, 8, target_padded, b"\x00" * 6, flags, 0)
|
|
109
110
|
|
|
110
111
|
# Protocol Header (12 bytes)
|
|
111
112
|
struct.pack_into("<QHH", header, 24, 0, pkt_type, 0)
|
|
@@ -277,12 +278,14 @@ class MatrixPacketGenerator(PacketGenerator):
|
|
|
277
278
|
# Combine header + payload
|
|
278
279
|
packet = header + payload
|
|
279
280
|
|
|
281
|
+
color_count = min(self._pixels_per_tile, 64)
|
|
280
282
|
templates.append(
|
|
281
283
|
PacketTemplate(
|
|
282
284
|
data=packet,
|
|
283
285
|
color_offset=HEADER_SIZE + self._COLORS_OFFSET_IN_PAYLOAD,
|
|
284
|
-
color_count=
|
|
286
|
+
color_count=color_count,
|
|
285
287
|
hsbk_start=tile_idx * self._pixels_per_tile,
|
|
288
|
+
fmt=f"<{'HHHH' * color_count}",
|
|
286
289
|
)
|
|
287
290
|
)
|
|
288
291
|
|
|
@@ -338,6 +341,7 @@ class MatrixPacketGenerator(PacketGenerator):
|
|
|
338
341
|
color_offset=HEADER_SIZE + self._COLORS_OFFSET_IN_PAYLOAD,
|
|
339
342
|
color_count=color_count,
|
|
340
343
|
hsbk_start=tile_pixel_start + color_start,
|
|
344
|
+
fmt=f"<{'HHHH' * color_count}",
|
|
341
345
|
)
|
|
342
346
|
)
|
|
343
347
|
|
|
@@ -369,6 +373,7 @@ class MatrixPacketGenerator(PacketGenerator):
|
|
|
369
373
|
color_offset=0, # No colors
|
|
370
374
|
color_count=0,
|
|
371
375
|
hsbk_start=0,
|
|
376
|
+
fmt="",
|
|
372
377
|
)
|
|
373
378
|
)
|
|
374
379
|
|
|
@@ -387,10 +392,13 @@ class MatrixPacketGenerator(PacketGenerator):
|
|
|
387
392
|
if tmpl.color_count == 0:
|
|
388
393
|
continue # Skip CopyFrameBuffer packets
|
|
389
394
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
395
|
+
# Flatten HSBK tuples and pack in one bulk call
|
|
396
|
+
start = tmpl.hsbk_start
|
|
397
|
+
end = start + tmpl.color_count
|
|
398
|
+
flat: list[int] = []
|
|
399
|
+
for h, s, b, k in hsbk[start:end]:
|
|
400
|
+
flat.extend((h, s, b, k))
|
|
401
|
+
struct.pack_into(tmpl.fmt, tmpl.data, tmpl.color_offset, *flat)
|
|
394
402
|
|
|
395
403
|
|
|
396
404
|
class MultiZonePacketGenerator(PacketGenerator):
|
|
@@ -476,6 +484,7 @@ class MultiZonePacketGenerator(PacketGenerator):
|
|
|
476
484
|
color_offset=HEADER_SIZE + self._COLORS_OFFSET_IN_PAYLOAD,
|
|
477
485
|
color_count=zone_count,
|
|
478
486
|
hsbk_start=zone_start,
|
|
487
|
+
fmt=f"<{'HHHH' * zone_count}",
|
|
479
488
|
)
|
|
480
489
|
)
|
|
481
490
|
|
|
@@ -491,7 +500,10 @@ class MultiZonePacketGenerator(PacketGenerator):
|
|
|
491
500
|
hsbk: Protocol-ready HSBK data for all zones
|
|
492
501
|
"""
|
|
493
502
|
for tmpl in templates:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
503
|
+
# Flatten HSBK tuples and pack in one bulk call
|
|
504
|
+
start = tmpl.hsbk_start
|
|
505
|
+
end = start + tmpl.color_count
|
|
506
|
+
flat: list[int] = []
|
|
507
|
+
for h, s, b, k in hsbk[start:end]:
|
|
508
|
+
flat.extend((h, s, b, k))
|
|
509
|
+
struct.pack_into(tmpl.fmt, tmpl.data, tmpl.color_offset, *flat)
|
lifx/devices/base.py
CHANGED
|
@@ -710,29 +710,29 @@ class Device(Generic[StateT]):
|
|
|
710
710
|
|
|
711
711
|
return self._mac_address
|
|
712
712
|
|
|
713
|
-
|
|
714
|
-
|
|
713
|
+
def _process_capabilities(
|
|
714
|
+
self, version: DeviceVersion, host_firmware: FirmwareInfo
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Process device capabilities from already-fetched version and firmware data.
|
|
715
717
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
the capability is removed.
|
|
718
|
+
Looks up product info from version.product, checks extended_multizone firmware
|
|
719
|
+
requirement, and sets self._capabilities. No-op if capabilities already set.
|
|
719
720
|
|
|
720
|
-
|
|
721
|
+
Args:
|
|
722
|
+
version: Device version info (vendor, product)
|
|
723
|
+
host_firmware: Host firmware info for extended multizone check
|
|
721
724
|
"""
|
|
722
|
-
if self._capabilities is not None:
|
|
725
|
+
if self._capabilities is not None:
|
|
723
726
|
return
|
|
724
727
|
|
|
725
|
-
# Get device version to determine product ID
|
|
726
|
-
version = await self.get_version()
|
|
727
728
|
self._capabilities = get_product(version.product)
|
|
728
729
|
|
|
729
730
|
# If device has extended_multizone with minimum firmware requirement, verify it
|
|
730
731
|
if self._capabilities and self._capabilities.has_extended_multizone:
|
|
731
732
|
if self._capabilities.min_ext_mz_firmware is not None:
|
|
732
|
-
firmware = await self.get_host_firmware()
|
|
733
733
|
firmware_version = (
|
|
734
|
-
|
|
735
|
-
) |
|
|
734
|
+
host_firmware.version_major << 16
|
|
735
|
+
) | host_firmware.version_minor
|
|
736
736
|
|
|
737
737
|
# If firmware is too old, remove the extended_multizone capability
|
|
738
738
|
if (
|
|
@@ -744,6 +744,23 @@ class Device(Generic[StateT]):
|
|
|
744
744
|
~ProductCapability.EXTENDED_MULTIZONE
|
|
745
745
|
)
|
|
746
746
|
|
|
747
|
+
async def _ensure_capabilities(self) -> None:
|
|
748
|
+
"""Ensure device capabilities are populated.
|
|
749
|
+
|
|
750
|
+
This fetches the device version and firmware to determine product capabilities.
|
|
751
|
+
If the device claims extended_multizone support but firmware is too old,
|
|
752
|
+
the capability is removed.
|
|
753
|
+
|
|
754
|
+
Called automatically when entering context manager, but can be called manually.
|
|
755
|
+
"""
|
|
756
|
+
if self._capabilities is not None: # pragma: no cover
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
# Get device version to determine product ID
|
|
760
|
+
version = await self.get_version()
|
|
761
|
+
host_firmware = await self.get_host_firmware()
|
|
762
|
+
self._process_capabilities(version, host_firmware)
|
|
763
|
+
|
|
747
764
|
@property
|
|
748
765
|
def capabilities(self) -> ProductInfo | None:
|
|
749
766
|
"""Get device product capabilities.
|
|
@@ -1661,32 +1678,62 @@ class Device(Generic[StateT]):
|
|
|
1661
1678
|
This is an all-or-nothing operation - either all state is fetched successfully
|
|
1662
1679
|
or an exception is raised.
|
|
1663
1680
|
|
|
1681
|
+
When capabilities are not pre-loaded, get_version() runs in parallel with the
|
|
1682
|
+
other GET requests to save one network round-trip.
|
|
1683
|
+
|
|
1664
1684
|
Raises:
|
|
1665
1685
|
LifxTimeoutError: If device does not respond within timeout
|
|
1666
1686
|
LifxDeviceNotFoundError: If device cannot be reached
|
|
1667
1687
|
LifxProtocolError: If responses are invalid
|
|
1668
1688
|
"""
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1689
|
+
if self._capabilities is not None:
|
|
1690
|
+
# Capabilities pre-loaded: skip get_version(), run 6 requests in parallel
|
|
1691
|
+
capabilities = self._create_capabilities()
|
|
1692
|
+
|
|
1693
|
+
(
|
|
1694
|
+
label,
|
|
1695
|
+
power,
|
|
1696
|
+
host_firmware,
|
|
1697
|
+
wifi_firmware,
|
|
1698
|
+
location_info,
|
|
1699
|
+
group_info,
|
|
1700
|
+
) = await asyncio.gather(
|
|
1701
|
+
self.get_label(),
|
|
1702
|
+
self.get_power(),
|
|
1703
|
+
self.get_host_firmware(),
|
|
1704
|
+
self.get_wifi_firmware(),
|
|
1705
|
+
self.get_location(),
|
|
1706
|
+
self.get_group(),
|
|
1707
|
+
)
|
|
1708
|
+
else:
|
|
1709
|
+
# Capabilities not loaded: include get_version() in parallel batch
|
|
1710
|
+
# Schedule get_version() concurrently alongside the 6-arg gather
|
|
1711
|
+
version_task = asyncio.ensure_future(self.get_version())
|
|
1712
|
+
try:
|
|
1713
|
+
(
|
|
1714
|
+
label,
|
|
1715
|
+
power,
|
|
1716
|
+
host_firmware,
|
|
1717
|
+
wifi_firmware,
|
|
1718
|
+
location_info,
|
|
1719
|
+
group_info,
|
|
1720
|
+
) = await asyncio.gather(
|
|
1721
|
+
self.get_label(),
|
|
1722
|
+
self.get_power(),
|
|
1723
|
+
self.get_host_firmware(),
|
|
1724
|
+
self.get_wifi_firmware(),
|
|
1725
|
+
self.get_location(),
|
|
1726
|
+
self.get_group(),
|
|
1727
|
+
)
|
|
1728
|
+
version = await version_task
|
|
1729
|
+
except Exception:
|
|
1730
|
+
if not version_task.done():
|
|
1731
|
+
version_task.cancel()
|
|
1732
|
+
# Await to consume cancellation and avoid leaked task warnings
|
|
1733
|
+
await asyncio.gather(version_task, return_exceptions=True)
|
|
1734
|
+
raise
|
|
1735
|
+
self._process_capabilities(version, host_firmware)
|
|
1736
|
+
capabilities = self._create_capabilities()
|
|
1690
1737
|
|
|
1691
1738
|
# Get MAC address (already calculated in get_host_firmware)
|
|
1692
1739
|
mac_address = await self.get_mac_address()
|
lifx/devices/light.py
CHANGED
|
@@ -946,9 +946,8 @@ class Light(Device[LightState]):
|
|
|
946
946
|
"""Initialize light state transactionally.
|
|
947
947
|
|
|
948
948
|
Extends base implementation to fetch color in addition to base state.
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
timeout: Timeout for state initialization
|
|
949
|
+
When capabilities are not pre-loaded, get_version() runs in parallel with
|
|
950
|
+
the other GET requests to save one network round-trip.
|
|
952
951
|
|
|
953
952
|
Raises:
|
|
954
953
|
LifxTimeoutError: If device does not respond within timeout
|
|
@@ -957,26 +956,50 @@ class Light(Device[LightState]):
|
|
|
957
956
|
"""
|
|
958
957
|
import time
|
|
959
958
|
|
|
960
|
-
# Ensure capabilities are loaded
|
|
961
|
-
await self._ensure_capabilities()
|
|
962
|
-
capabilities = self._create_capabilities()
|
|
963
|
-
|
|
964
|
-
# Fetch semi-static and volatile state in parallel
|
|
965
|
-
# get_color returns color, power, and label in one request
|
|
966
959
|
try:
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
960
|
+
if self._capabilities is not None:
|
|
961
|
+
# Capabilities pre-loaded: skip get_version()
|
|
962
|
+
capabilities = self._create_capabilities()
|
|
963
|
+
|
|
964
|
+
(
|
|
965
|
+
(color, power, label),
|
|
966
|
+
host_firmware,
|
|
967
|
+
wifi_firmware,
|
|
968
|
+
location_info,
|
|
969
|
+
group_info,
|
|
970
|
+
) = await asyncio.gather(
|
|
971
|
+
self.get_color(),
|
|
972
|
+
self.get_host_firmware(),
|
|
973
|
+
self.get_wifi_firmware(),
|
|
974
|
+
self.get_location(),
|
|
975
|
+
self.get_group(),
|
|
976
|
+
)
|
|
977
|
+
else:
|
|
978
|
+
# Capabilities not loaded: include get_version() in parallel batch
|
|
979
|
+
version_task = asyncio.ensure_future(self.get_version())
|
|
980
|
+
try:
|
|
981
|
+
(
|
|
982
|
+
(color, power, label),
|
|
983
|
+
host_firmware,
|
|
984
|
+
wifi_firmware,
|
|
985
|
+
location_info,
|
|
986
|
+
group_info,
|
|
987
|
+
) = await asyncio.gather(
|
|
988
|
+
self.get_color(),
|
|
989
|
+
self.get_host_firmware(),
|
|
990
|
+
self.get_wifi_firmware(),
|
|
991
|
+
self.get_location(),
|
|
992
|
+
self.get_group(),
|
|
993
|
+
)
|
|
994
|
+
version = await version_task
|
|
995
|
+
except Exception:
|
|
996
|
+
if not version_task.done():
|
|
997
|
+
version_task.cancel()
|
|
998
|
+
# Await to consume cancellation and avoid leaked task warnings
|
|
999
|
+
await asyncio.gather(version_task, return_exceptions=True)
|
|
1000
|
+
raise
|
|
1001
|
+
self._process_capabilities(version, host_firmware)
|
|
1002
|
+
capabilities = self._create_capabilities()
|
|
980
1003
|
|
|
981
1004
|
# Get MAC address (already calculated in get_host_firmware)
|
|
982
1005
|
mac_address = await self.get_mac_address()
|
lifx/network/connection.py
CHANGED
|
@@ -110,6 +110,21 @@ class DeviceConnection:
|
|
|
110
110
|
self._is_open = False
|
|
111
111
|
self._is_opening = False # Flag to prevent concurrent open() calls
|
|
112
112
|
|
|
113
|
+
# Pre-compute serial bytes for fast comparison in background receiver
|
|
114
|
+
self._is_discovery = serial == "000000000000"
|
|
115
|
+
if not self._is_discovery:
|
|
116
|
+
serial_obj = Serial.from_string(serial)
|
|
117
|
+
self._target_bytes: bytes | None = serial_obj.to_protocol()
|
|
118
|
+
else:
|
|
119
|
+
self._target_bytes = None
|
|
120
|
+
|
|
121
|
+
# Pre-compute target bytes for send_packet() to avoid
|
|
122
|
+
# re-parsing on every send
|
|
123
|
+
if self._target_bytes is not None:
|
|
124
|
+
self._send_target: bytes = self._target_bytes
|
|
125
|
+
else:
|
|
126
|
+
self._send_target: bytes = b"\x00" * 8
|
|
127
|
+
|
|
113
128
|
# Background receiver task infrastructure
|
|
114
129
|
# Key: (source, sequence, serial) → Queue of (header, payload) tuples
|
|
115
130
|
self._pending_requests: dict[
|
|
@@ -262,12 +277,11 @@ class DeviceConnection:
|
|
|
262
277
|
if source is None:
|
|
263
278
|
source = self._allocate_source()
|
|
264
279
|
|
|
265
|
-
target = Serial.from_string(self.serial).to_protocol()
|
|
266
280
|
message = create_message(
|
|
267
281
|
packet=packet,
|
|
268
282
|
source=source,
|
|
269
283
|
sequence=sequence,
|
|
270
|
-
target=
|
|
284
|
+
target=self._send_target,
|
|
271
285
|
ack_required=ack_required,
|
|
272
286
|
res_required=res_required,
|
|
273
287
|
)
|
|
@@ -360,12 +374,19 @@ class DeviceConnection:
|
|
|
360
374
|
)
|
|
361
375
|
|
|
362
376
|
# Compute correlation key (includes serial for defense-in-depth)
|
|
363
|
-
# For discovery connections
|
|
364
|
-
#
|
|
365
|
-
if self.
|
|
377
|
+
# For discovery connections, always use "000000000000" for correlation
|
|
378
|
+
# regardless of response serial
|
|
379
|
+
if self._is_discovery:
|
|
366
380
|
serial = "000000000000"
|
|
367
381
|
else:
|
|
368
|
-
|
|
382
|
+
# Compare target bytes directly to avoid string conversion
|
|
383
|
+
if (
|
|
384
|
+
self._target_bytes is not None
|
|
385
|
+
and header.target == self._target_bytes
|
|
386
|
+
):
|
|
387
|
+
serial = self.serial
|
|
388
|
+
else:
|
|
389
|
+
serial = Serial.from_protocol(header.target).to_string()
|
|
369
390
|
key = (header.source, header.sequence, serial)
|
|
370
391
|
|
|
371
392
|
# Route to waiting request
|
|
@@ -572,11 +593,14 @@ class DeviceConnection:
|
|
|
572
593
|
|
|
573
594
|
# Validate correlation (defense in depth)
|
|
574
595
|
# For discovery connections, skip serial validation
|
|
575
|
-
if self.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
596
|
+
if not self._is_discovery:
|
|
597
|
+
if (
|
|
598
|
+
self._target_bytes is not None
|
|
599
|
+
and header.target != self._target_bytes
|
|
600
|
+
):
|
|
601
|
+
response_serial = Serial.from_protocol(
|
|
602
|
+
header.target
|
|
603
|
+
).to_string()
|
|
580
604
|
raise LifxProtocolError(
|
|
581
605
|
f"Response serial mismatch: "
|
|
582
606
|
f"expected {self.serial}, got {response_serial}"
|
|
@@ -710,16 +734,21 @@ class DeviceConnection:
|
|
|
710
734
|
)
|
|
711
735
|
|
|
712
736
|
# Validate correlation
|
|
737
|
+
serial_mismatch = (
|
|
738
|
+
self._target_bytes is not None
|
|
739
|
+
and header.target != self._target_bytes
|
|
740
|
+
)
|
|
713
741
|
if (
|
|
714
742
|
header.source != request_source
|
|
715
743
|
or header.sequence != sequence
|
|
716
|
-
or
|
|
744
|
+
or serial_mismatch
|
|
717
745
|
):
|
|
746
|
+
response_serial = Serial.from_protocol(header.target).to_string()
|
|
718
747
|
raise LifxProtocolError(
|
|
719
748
|
f"ACK correlation mismatch: "
|
|
720
749
|
f"expected ({request_source}, {sequence}, {self.serial}), "
|
|
721
750
|
f"got ({header.source}, {header.sequence}, "
|
|
722
|
-
f"{
|
|
751
|
+
f"{response_serial})"
|
|
723
752
|
)
|
|
724
753
|
|
|
725
754
|
# Check for StateUnhandled - return False to indicate unsupported
|
|
@@ -848,6 +877,10 @@ class DeviceConnection:
|
|
|
848
877
|
serial = Serial(value=header.target_serial).to_string()
|
|
849
878
|
if self.serial == "000000000000" and serial != self.serial:
|
|
850
879
|
self.serial = serial
|
|
880
|
+
# Refresh cached fields now that we know the real serial
|
|
881
|
+
self._is_discovery = False
|
|
882
|
+
self._target_bytes = Serial.from_string(serial).to_protocol()
|
|
883
|
+
self._send_target = self._target_bytes
|
|
851
884
|
|
|
852
885
|
# Unpack (labels are automatically decoded by Packet.unpack())
|
|
853
886
|
response_packet = packet_class.unpack(payload)
|
lifx/protocol/base.py
CHANGED
|
@@ -6,8 +6,42 @@ Provides generic pack/unpack functionality for all packet types.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
import re
|
|
9
10
|
from dataclasses import asdict, dataclass
|
|
10
|
-
from typing import Any, ClassVar
|
|
11
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
|
|
16
|
+
# Lazy-cached module references to avoid circular imports at module load time.
|
|
17
|
+
# These modules import from each other indirectly through packets.py, so we
|
|
18
|
+
# defer the import to first use and cache the result at module level.
|
|
19
|
+
_serializer: ModuleType | None = None
|
|
20
|
+
_protocol_types: ModuleType | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_serializer() -> ModuleType:
|
|
24
|
+
"""Get the serializer module, importing on first use."""
|
|
25
|
+
global _serializer # noqa: PLW0603
|
|
26
|
+
if _serializer is None:
|
|
27
|
+
from lifx.protocol import serializer
|
|
28
|
+
|
|
29
|
+
_serializer = serializer
|
|
30
|
+
return _serializer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_protocol_types() -> ModuleType:
|
|
34
|
+
"""Get the protocol_types module, importing on first use."""
|
|
35
|
+
global _protocol_types # noqa: PLW0603
|
|
36
|
+
if _protocol_types is None:
|
|
37
|
+
from lifx.protocol import protocol_types
|
|
38
|
+
|
|
39
|
+
_protocol_types = protocol_types
|
|
40
|
+
return _protocol_types
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_ARRAY_PATTERN = re.compile(r"\[(\d+)\](.+)")
|
|
44
|
+
_PASCAL_TO_SNAKE = re.compile(r"(?<!^)(?=[A-Z])")
|
|
11
45
|
|
|
12
46
|
_LOGGER = logging.getLogger(__name__)
|
|
13
47
|
|
|
@@ -37,7 +71,7 @@ class Packet:
|
|
|
37
71
|
Returns:
|
|
38
72
|
Packed bytes ready to send in a LIFX message payload
|
|
39
73
|
"""
|
|
40
|
-
|
|
74
|
+
serializer = _get_serializer()
|
|
41
75
|
|
|
42
76
|
result = b""
|
|
43
77
|
|
|
@@ -154,14 +188,12 @@ class Packet:
|
|
|
154
188
|
@staticmethod
|
|
155
189
|
def _protocol_to_python_name(name: str) -> str:
|
|
156
190
|
"""Convert protocol name (PascalCase) to Python name (snake_case)."""
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
snake = re.sub(r"(?<!^)(?=[A-Z])", "_", name)
|
|
191
|
+
snake = _PASCAL_TO_SNAKE.sub("_", name)
|
|
160
192
|
return snake.lower()
|
|
161
193
|
|
|
162
194
|
def _pack_field_value(self, value: Any, field_type: str, size_bytes: int) -> bytes:
|
|
163
195
|
"""Pack a single field value based on its type."""
|
|
164
|
-
|
|
196
|
+
serializer = _get_serializer()
|
|
165
197
|
|
|
166
198
|
# Parse field type
|
|
167
199
|
base_type, array_count, is_nested = self._parse_field_type(field_type)
|
|
@@ -211,25 +243,19 @@ class Packet:
|
|
|
211
243
|
cls, data: bytes, field_type: str, size_bytes: int, offset: int
|
|
212
244
|
) -> tuple[Any, int]:
|
|
213
245
|
"""Unpack a single field value based on its type."""
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
DeviceService,
|
|
217
|
-
FirmwareEffect,
|
|
218
|
-
LightLastHevCycleResult,
|
|
219
|
-
LightWaveform,
|
|
220
|
-
MultiZoneApplicationRequest,
|
|
221
|
-
)
|
|
246
|
+
serializer = _get_serializer()
|
|
247
|
+
protocol_types = _get_protocol_types()
|
|
222
248
|
|
|
223
249
|
# Parse field type
|
|
224
250
|
base_type, array_count, is_nested = cls._parse_field_type(field_type)
|
|
225
251
|
|
|
226
252
|
# Check if it's an enum (Button/Relay enums excluded)
|
|
227
253
|
enum_types = {
|
|
228
|
-
"DeviceService": DeviceService,
|
|
229
|
-
"LightLastHevCycleResult": LightLastHevCycleResult,
|
|
230
|
-
"LightWaveform": LightWaveform,
|
|
231
|
-
"MultiZoneApplicationRequest": MultiZoneApplicationRequest,
|
|
232
|
-
"FirmwareEffect": FirmwareEffect,
|
|
254
|
+
"DeviceService": protocol_types.DeviceService,
|
|
255
|
+
"LightLastHevCycleResult": protocol_types.LightLastHevCycleResult,
|
|
256
|
+
"LightWaveform": protocol_types.LightWaveform,
|
|
257
|
+
"MultiZoneApplicationRequest": protocol_types.MultiZoneApplicationRequest,
|
|
258
|
+
"FirmwareEffect": protocol_types.FirmwareEffect,
|
|
233
259
|
}
|
|
234
260
|
is_enum = is_nested and base_type in enum_types
|
|
235
261
|
|
|
@@ -247,9 +273,7 @@ class Packet:
|
|
|
247
273
|
result.append(enum_class(item_raw))
|
|
248
274
|
return result, current_offset
|
|
249
275
|
elif is_nested:
|
|
250
|
-
# Array of nested structures
|
|
251
|
-
from lifx.protocol import protocol_types
|
|
252
|
-
|
|
276
|
+
# Array of nested structures
|
|
253
277
|
struct_class = getattr(protocol_types, base_type)
|
|
254
278
|
result = []
|
|
255
279
|
current_offset = offset
|
|
@@ -276,9 +300,7 @@ class Packet:
|
|
|
276
300
|
value_raw, new_offset = serializer.unpack_value(data, "uint8", offset)
|
|
277
301
|
return enum_class(value_raw), new_offset
|
|
278
302
|
elif is_nested:
|
|
279
|
-
# Nested structure
|
|
280
|
-
from lifx.protocol import protocol_types
|
|
281
|
-
|
|
303
|
+
# Nested structure
|
|
282
304
|
struct_class = getattr(protocol_types, base_type)
|
|
283
305
|
# Check if it's a Packet subclass or protocol_types class
|
|
284
306
|
if issubclass(struct_class, cls):
|
|
@@ -324,10 +346,8 @@ class Packet:
|
|
|
324
346
|
Returns:
|
|
325
347
|
Tuple of (base_type, array_count, is_nested)
|
|
326
348
|
"""
|
|
327
|
-
import re
|
|
328
|
-
|
|
329
349
|
# Check for array: [N]type
|
|
330
|
-
array_match =
|
|
350
|
+
array_match = _ARRAY_PATTERN.match(field_type)
|
|
331
351
|
if array_match:
|
|
332
352
|
count = int(array_match.group(1))
|
|
333
353
|
inner_type = array_match.group(2)
|
lifx/protocol/header.py
CHANGED
|
@@ -141,8 +141,8 @@ class LifxHeader:
|
|
|
141
141
|
flags = (int(self.res_required) & 0b1) | ((int(self.ack_required) & 0b1) << 1)
|
|
142
142
|
|
|
143
143
|
frame_addr = struct.pack(
|
|
144
|
-
"<
|
|
145
|
-
|
|
144
|
+
"<8s6sBB",
|
|
145
|
+
self.target,
|
|
146
146
|
b"\x00" * 6, # reserved
|
|
147
147
|
flags,
|
|
148
148
|
self.sequence,
|
|
@@ -189,8 +189,7 @@ class LifxHeader:
|
|
|
189
189
|
raise ValueError("Addressable bit must be set")
|
|
190
190
|
|
|
191
191
|
# Unpack Frame Address (16 bytes)
|
|
192
|
-
|
|
193
|
-
target = target_int.to_bytes(8, byteorder="little")
|
|
192
|
+
target, _reserved, flags, sequence = struct.unpack("<8s6sBB", data[8:24])
|
|
194
193
|
|
|
195
194
|
res_required = bool(flags & 0b1)
|
|
196
195
|
ack_required = bool((flags >> 1) & 0b1)
|
|
@@ -8,13 +8,13 @@ lifx/animation/__init__.py,sha256=1EqPk26BTEADYR5qLD1UKb7cNnVfnLo5vfAnix6yyu0,23
|
|
|
8
8
|
lifx/animation/animator.py,sha256=LyruwE4Z0gHnkGJfgug017-Mt0R-3FCrDoEsqGjE5ps,10154
|
|
9
9
|
lifx/animation/framebuffer.py,sha256=ya3mbYtu5hepn2Fxfc3wEck5KhmmAfcbmv0RoIO-yC4,14116
|
|
10
10
|
lifx/animation/orientation.py,sha256=bUMV4czLn5PH3inMObfyvbYD-jaSSH-LWaLOby-mvTs,5839
|
|
11
|
-
lifx/animation/packets.py,sha256=
|
|
11
|
+
lifx/animation/packets.py,sha256=dnbwpmuirCbxph9mK_FNb7WCi7Mju-pRoVKfcIOIfUI,17498
|
|
12
12
|
lifx/devices/__init__.py,sha256=4b5QtO0EFWxIqN2lUYgM8uLjWyHI5hUcReiF9QCjCGw,1061
|
|
13
|
-
lifx/devices/base.py,sha256=
|
|
13
|
+
lifx/devices/base.py,sha256=NV0GxG0-O7VLT55_z6nBqdU-YGHxdmaPs2Ki00EZkRY,65200
|
|
14
14
|
lifx/devices/ceiling.py,sha256=cmGeEyads2O5e2H2VBsk6n0An4dZtT59HQvN2F9b4gA,45771
|
|
15
15
|
lifx/devices/hev.py,sha256=kTRJRYnWyIY8Pkg_jOn978N-_1YXy9fRmBiGgEWscXw,15194
|
|
16
16
|
lifx/devices/infrared.py,sha256=ePk9qxX_s-hv5gQMvio1Vv8FYiCd68HF0ySbWgSrvuU,8130
|
|
17
|
-
lifx/devices/light.py,sha256=
|
|
17
|
+
lifx/devices/light.py,sha256=9yjmEeUqO4ATK6NaDMnlDzdddHlCouMlJcjotgCZzkI,35074
|
|
18
18
|
lifx/devices/matrix.py,sha256=reB6cS2_cFe3qZKg584oCO-JGLbNTJDWwW9FZ0NLxq0,41693
|
|
19
19
|
lifx/devices/multizone.py,sha256=7Te5Z_X9hDvdypjMqPGGM2TG0P9QltzFVi7UUxRdbGI,33326
|
|
20
20
|
lifx/effects/__init__.py,sha256=4DF31yp7RJic5JoltMlz5dCtF5KQobU6NOUtLUKkVKE,1509
|
|
@@ -26,7 +26,7 @@ lifx/effects/models.py,sha256=MS5D-cxD0Ar8XhqbqKAc9q2sk38IP1vPkYwd8V7jCr8,2446
|
|
|
26
26
|
lifx/effects/pulse.py,sha256=k4dtBhhgVHyuwzqzx89jYVKbSRUVQdZj91cklyKarbE,8455
|
|
27
27
|
lifx/effects/state_manager.py,sha256=iDfYowiCN5IJqcR1s-pM0mQEJpe-RDsMcOOSMmtPVDE,8983
|
|
28
28
|
lifx/network/__init__.py,sha256=uSyA8r8qISG7qXUHbX8uk9A2E8rvDADgCcf94QIZ9so,499
|
|
29
|
-
lifx/network/connection.py,sha256=
|
|
29
|
+
lifx/network/connection.py,sha256=XUPSHBkeR3cLVwglJWmPZqSYUTPoB5_kzRMJXBgzixs,39866
|
|
30
30
|
lifx/network/discovery.py,sha256=J0B3yRkbZKx7g01CCIEnjv3gtqSORhmdTYQ6w0ea4WI,24178
|
|
31
31
|
lifx/network/message.py,sha256=jCLC9v0tbBi54g5CaHLFM_nP1Izu8kJmo2tt23HHBbA,2600
|
|
32
32
|
lifx/network/transport.py,sha256=EykhKmvjAcdiepgCxgzDTj8Fc0b7kAQdyR8AumGXohg,10953
|
|
@@ -41,9 +41,9 @@ lifx/products/generator.py,sha256=DsTCJcEVPmn9sfXSbXYdFZjqMfIbodnIQL46DRASs0g,15
|
|
|
41
41
|
lifx/products/quirks.py,sha256=B8Kb4pxaXmovMbjgXRfPPWre5JEvJrn8d6PAWK_FT1U,2544
|
|
42
42
|
lifx/products/registry.py,sha256=ILIJlQxcxJUzRH-LGU_bnHjV-TxDEucKovuJcWvG4q8,43831
|
|
43
43
|
lifx/protocol/__init__.py,sha256=-wjC-wBcb7fxi5I-mJr2Ad8K2YRflJFdLLdobfD-W1Q,56
|
|
44
|
-
lifx/protocol/base.py,sha256=
|
|
44
|
+
lifx/protocol/base.py,sha256=xqO3qYpiSMY-_vcR6P2OEgITIbzm0AKRZs1yzOz7Y7M,13078
|
|
45
45
|
lifx/protocol/generator.py,sha256=RWu4zC-b_JhonLM5fbAufQyKxEI2myLyCnt7tu3POMI,60690
|
|
46
|
-
lifx/protocol/header.py,sha256=
|
|
46
|
+
lifx/protocol/header.py,sha256=8KXe6C-T1XMyzepFV9fJ4_gsnO05HDk07XVtkwhiEU0,7076
|
|
47
47
|
lifx/protocol/models.py,sha256=eOvOSAWbglR1SYWcC_YpicewtsdbVlQ6E2lfcC4NQrk,8172
|
|
48
48
|
lifx/protocol/packets.py,sha256=ENp3irGITdV5rGah3eUzgsXqihI95upAPh7AdTsP7sk,43303
|
|
49
49
|
lifx/protocol/protocol_types.py,sha256=m15A82zVrwAXomTqo-GfNmAIynVRDSV94UqHDkWgiJI,23781
|
|
@@ -53,7 +53,7 @@ lifx/theme/canvas.py,sha256=4h7lgN8iu_OdchObGDgbxTqQLCb-FRKC-M-YCWef_i4,8048
|
|
|
53
53
|
lifx/theme/generators.py,sha256=nq3Yvntq_h-eFHbmmow3LcAdA_hEbRRaP5mv9Bydrjk,6435
|
|
54
54
|
lifx/theme/library.py,sha256=tKlKZNqJp8lRGDnilWyDm_Qr1vCRGGwuvWVS82anNpQ,21326
|
|
55
55
|
lifx/theme/theme.py,sha256=qMEx_8E41C0Cc6f083XHiAXEglTv4YlXW0UFsG1rQKg,5521
|
|
56
|
-
lifx_async-5.1.
|
|
57
|
-
lifx_async-5.1.
|
|
58
|
-
lifx_async-5.1.
|
|
59
|
-
lifx_async-5.1.
|
|
56
|
+
lifx_async-5.1.1.dist-info/METADATA,sha256=Dd2gx4lZLL_JVddm3LeWwOgBWxm_JNAw1FkgWV0u6qw,2660
|
|
57
|
+
lifx_async-5.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
58
|
+
lifx_async-5.1.1.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
|
|
59
|
+
lifx_async-5.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|