lifx-async 5.0.0__py3-none-any.whl → 5.1.0__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/__init__.py +4 -0
- lifx/animation/__init__.py +87 -0
- lifx/animation/animator.py +323 -0
- lifx/animation/framebuffer.py +395 -0
- lifx/animation/orientation.py +159 -0
- lifx/animation/packets.py +497 -0
- lifx/api.py +0 -8
- lifx/const.py +18 -0
- lifx/devices/ceiling.py +7 -8
- lifx/devices/matrix.py +2 -19
- lifx/effects/colorloop.py +2 -2
- lifx/network/connection.py +8 -7
- lifx/network/discovery.py +3 -3
- lifx/network/mdns/transport.py +2 -2
- lifx/network/transport.py +8 -3
- lifx/network/utils.py +15 -0
- lifx/protocol/serializer.py +0 -85
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/METADATA +1 -1
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/RECORD +21 -15
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/WHEEL +0 -0
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Device-specific packet generators for animation.
|
|
2
|
+
|
|
3
|
+
This module provides packet generators that create prebaked packet templates
|
|
4
|
+
for high-performance animation. All packets (header + payload) are prebaked
|
|
5
|
+
at initialization time, and only color data and sequence numbers are updated
|
|
6
|
+
per frame.
|
|
7
|
+
|
|
8
|
+
**Performance Optimization:**
|
|
9
|
+
- Complete packets (header + payload) are prebaked as bytearrays
|
|
10
|
+
- Per-frame updates only touch color bytes and sequence number
|
|
11
|
+
- Zero object allocation in the hot path
|
|
12
|
+
- Direct struct.pack_into for color updates
|
|
13
|
+
|
|
14
|
+
Supported Devices:
|
|
15
|
+
- MatrixLight: Uses Set64 packets (64 pixels per packet per tile)
|
|
16
|
+
- MultiZoneLight: Uses SetExtendedColorZones (82 zones per packet)
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
```python
|
|
20
|
+
from lifx.animation.packets import MatrixPacketGenerator
|
|
21
|
+
|
|
22
|
+
# Create generator and prebake packets
|
|
23
|
+
gen = MatrixPacketGenerator(tile_count=1, tile_width=8, tile_height=8)
|
|
24
|
+
templates = gen.create_templates(source=12345, target=b"\\xd0\\x73...")
|
|
25
|
+
|
|
26
|
+
# Per-frame: update colors and send
|
|
27
|
+
gen.update_colors(templates, hsbk_data)
|
|
28
|
+
for tmpl in templates:
|
|
29
|
+
tmpl.data[23] = sequence # Update sequence byte
|
|
30
|
+
socket.sendto(tmpl.data, (ip, port))
|
|
31
|
+
```
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import struct
|
|
37
|
+
from abc import ABC, abstractmethod
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import ClassVar
|
|
40
|
+
|
|
41
|
+
# Header constants
|
|
42
|
+
HEADER_SIZE = 36
|
|
43
|
+
SEQUENCE_OFFSET = 23 # Offset of sequence byte in header
|
|
44
|
+
|
|
45
|
+
# Header field values for animation packets
|
|
46
|
+
PROTOCOL_NUMBER = 1024
|
|
47
|
+
ORIGIN = 0
|
|
48
|
+
ADDRESSABLE = 1
|
|
49
|
+
TAGGED = 0
|
|
50
|
+
ACK_REQUIRED = 0
|
|
51
|
+
RES_REQUIRED = 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class PacketTemplate:
|
|
56
|
+
"""Prebaked packet template for zero-allocation animation.
|
|
57
|
+
|
|
58
|
+
Contains a complete packet (header + payload) as a mutable bytearray.
|
|
59
|
+
Only the sequence byte and color data need to be updated per frame.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
data: Complete packet bytes (header + payload)
|
|
63
|
+
color_offset: Byte offset where color data starts
|
|
64
|
+
color_count: Number of HSBK colors in this packet
|
|
65
|
+
hsbk_start: Starting index in the input HSBK array
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
data: bytearray
|
|
69
|
+
color_offset: int
|
|
70
|
+
color_count: int
|
|
71
|
+
hsbk_start: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _build_header(
|
|
75
|
+
pkt_type: int,
|
|
76
|
+
source: int,
|
|
77
|
+
target: bytes,
|
|
78
|
+
payload_size: int,
|
|
79
|
+
) -> bytearray:
|
|
80
|
+
"""Build a LIFX header as a bytearray.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
pkt_type: Packet type identifier
|
|
84
|
+
source: Client source ID
|
|
85
|
+
target: 6-byte device serial
|
|
86
|
+
payload_size: Size of payload in bytes
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
36-byte header as bytearray
|
|
90
|
+
"""
|
|
91
|
+
header = bytearray(HEADER_SIZE)
|
|
92
|
+
|
|
93
|
+
# Frame (8 bytes)
|
|
94
|
+
size = HEADER_SIZE + payload_size
|
|
95
|
+
protocol_field = (
|
|
96
|
+
(ORIGIN & 0b11) << 14
|
|
97
|
+
| (TAGGED & 0b1) << 13
|
|
98
|
+
| (ADDRESSABLE & 0b1) << 12
|
|
99
|
+
| (PROTOCOL_NUMBER & 0xFFF)
|
|
100
|
+
)
|
|
101
|
+
struct.pack_into("<HHI", header, 0, size, protocol_field, source)
|
|
102
|
+
|
|
103
|
+
# Frame Address (16 bytes)
|
|
104
|
+
# target (8 bytes) + reserved (6 bytes) + flags (1 byte) + sequence (1 byte)
|
|
105
|
+
target_padded = target + b"\x00\x00" if len(target) == 6 else target
|
|
106
|
+
target_int = int.from_bytes(target_padded, byteorder="little")
|
|
107
|
+
flags = (RES_REQUIRED & 0b1) | ((ACK_REQUIRED & 0b1) << 1)
|
|
108
|
+
struct.pack_into("<Q6sBB", header, 8, target_int, b"\x00" * 6, flags, 0)
|
|
109
|
+
|
|
110
|
+
# Protocol Header (12 bytes)
|
|
111
|
+
struct.pack_into("<QHH", header, 24, 0, pkt_type, 0)
|
|
112
|
+
|
|
113
|
+
return header
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PacketGenerator(ABC):
|
|
117
|
+
"""Abstract base class for packet generators.
|
|
118
|
+
|
|
119
|
+
Packet generators prebake complete packets (header + payload) at
|
|
120
|
+
initialization time. Per-frame, only color data and sequence numbers
|
|
121
|
+
are updated in place.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def create_templates(self, source: int, target: bytes) -> list[PacketTemplate]:
|
|
126
|
+
"""Create prebaked packet templates.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
source: Client source ID for header
|
|
130
|
+
target: 6-byte device serial for header
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
List of PacketTemplate with prebaked packets
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
def update_colors(
|
|
138
|
+
self, templates: list[PacketTemplate], hsbk: list[tuple[int, int, int, int]]
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Update color data in prebaked templates.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
templates: Prebaked packet templates
|
|
144
|
+
hsbk: Protocol-ready HSBK data for all pixels
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
@abstractmethod
|
|
148
|
+
def pixel_count(self) -> int:
|
|
149
|
+
"""Get the total pixel count this generator expects."""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MatrixPacketGenerator(PacketGenerator):
|
|
153
|
+
"""Packet generator for MatrixLight devices.
|
|
154
|
+
|
|
155
|
+
Generates Set64 packets for all tiles. Uses prebaked packet templates
|
|
156
|
+
with complete headers for maximum performance.
|
|
157
|
+
|
|
158
|
+
For standard tiles (≤64 pixels):
|
|
159
|
+
- Single Set64 packet directly to display buffer (fb_index=0)
|
|
160
|
+
|
|
161
|
+
For large tiles (>64 pixels, e.g., Ceiling 16x8=128):
|
|
162
|
+
- Multiple Set64 packets to temp buffer (fb_index=1)
|
|
163
|
+
- CopyFrameBuffer packet to copy fb_index=1 → fb_index=0
|
|
164
|
+
|
|
165
|
+
Set64 Payload Layout (522 bytes):
|
|
166
|
+
- Offset 0: tile_index (uint8)
|
|
167
|
+
- Offset 1: length (uint8, always 1)
|
|
168
|
+
- Offset 2-5: TileBufferRect (fb_index, x, y, width - 4 x uint8)
|
|
169
|
+
- Offset 6-9: duration (uint32)
|
|
170
|
+
- Offset 10-521: colors (64 x HSBK, each 8 bytes)
|
|
171
|
+
|
|
172
|
+
CopyFrameBuffer Payload Layout (15 bytes):
|
|
173
|
+
- Offset 0: tile_index (uint8)
|
|
174
|
+
- Offset 1: length (uint8, always 1)
|
|
175
|
+
- Offset 2: src_fb_index (uint8, 1 = temp buffer)
|
|
176
|
+
- Offset 3: dst_fb_index (uint8, 0 = display)
|
|
177
|
+
- Offset 4-7: src_x, src_y, dst_x, dst_y (uint8 each)
|
|
178
|
+
- Offset 8-9: width, height (uint8 each)
|
|
179
|
+
- Offset 10-13: duration (uint32)
|
|
180
|
+
- Offset 14: reserved (uint8)
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
# Packet types
|
|
184
|
+
SET64_PKT_TYPE: ClassVar[int] = 715
|
|
185
|
+
COPY_FRAME_BUFFER_PKT_TYPE: ClassVar[int] = 716
|
|
186
|
+
|
|
187
|
+
# Set64 payload layout
|
|
188
|
+
_SET64_PAYLOAD_SIZE: ClassVar[int] = 522
|
|
189
|
+
_COLORS_OFFSET_IN_PAYLOAD: ClassVar[int] = 10
|
|
190
|
+
_MAX_COLORS_PER_PACKET: ClassVar[int] = 64
|
|
191
|
+
|
|
192
|
+
# CopyFrameBuffer payload layout
|
|
193
|
+
_COPY_FB_PAYLOAD_SIZE: ClassVar[int] = 15
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
tile_count: int,
|
|
198
|
+
tile_width: int,
|
|
199
|
+
tile_height: int,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Initialize matrix packet generator.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
tile_count: Number of tiles in the device chain
|
|
205
|
+
tile_width: Width of each tile in pixels
|
|
206
|
+
tile_height: Height of each tile in pixels
|
|
207
|
+
"""
|
|
208
|
+
self._tile_count = tile_count
|
|
209
|
+
self._tile_width = tile_width
|
|
210
|
+
self._tile_height = tile_height
|
|
211
|
+
self._pixels_per_tile = tile_width * tile_height
|
|
212
|
+
self._total_pixels = tile_count * self._pixels_per_tile
|
|
213
|
+
|
|
214
|
+
# Determine if we need large tile mode (>64 pixels per tile)
|
|
215
|
+
self._is_large_tile = self._pixels_per_tile > self._MAX_COLORS_PER_PACKET
|
|
216
|
+
|
|
217
|
+
# Calculate packets needed per tile
|
|
218
|
+
self._rows_per_packet = self._MAX_COLORS_PER_PACKET // tile_width
|
|
219
|
+
self._packets_per_tile = (
|
|
220
|
+
self._pixels_per_tile + self._MAX_COLORS_PER_PACKET - 1
|
|
221
|
+
) // self._MAX_COLORS_PER_PACKET
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def is_large_tile(self) -> bool:
|
|
225
|
+
"""Check if tiles have >64 pixels (requires multi-packet strategy)."""
|
|
226
|
+
return self._is_large_tile
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def packets_per_tile(self) -> int:
|
|
230
|
+
"""Get number of Set64 packets needed per tile."""
|
|
231
|
+
return self._packets_per_tile
|
|
232
|
+
|
|
233
|
+
def pixel_count(self) -> int:
|
|
234
|
+
"""Get total pixel count."""
|
|
235
|
+
return self._total_pixels
|
|
236
|
+
|
|
237
|
+
def create_templates(self, source: int, target: bytes) -> list[PacketTemplate]:
|
|
238
|
+
"""Create prebaked packet templates for all tiles.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
source: Client source ID
|
|
242
|
+
target: 6-byte device serial
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
List of PacketTemplate with complete prebaked packets
|
|
246
|
+
"""
|
|
247
|
+
if self._is_large_tile:
|
|
248
|
+
return self._create_large_tile_templates(source, target)
|
|
249
|
+
else:
|
|
250
|
+
return self._create_standard_templates(source, target)
|
|
251
|
+
|
|
252
|
+
def _create_standard_templates(
|
|
253
|
+
self, source: int, target: bytes
|
|
254
|
+
) -> list[PacketTemplate]:
|
|
255
|
+
"""Create templates for standard tiles (≤64 pixels each)."""
|
|
256
|
+
templates: list[PacketTemplate] = []
|
|
257
|
+
|
|
258
|
+
for tile_idx in range(self._tile_count):
|
|
259
|
+
# Build header
|
|
260
|
+
header = _build_header(
|
|
261
|
+
self.SET64_PKT_TYPE, source, target, self._SET64_PAYLOAD_SIZE
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Build payload
|
|
265
|
+
payload = bytearray(self._SET64_PAYLOAD_SIZE)
|
|
266
|
+
payload[0] = tile_idx # tile_index
|
|
267
|
+
payload[1] = 1 # length
|
|
268
|
+
# TileBufferRect: fb_index=0, x=0, y=0, width=tile_width
|
|
269
|
+
struct.pack_into("<BBBB", payload, 2, 0, 0, 0, self._tile_width)
|
|
270
|
+
# duration = 0
|
|
271
|
+
struct.pack_into("<I", payload, 6, 0)
|
|
272
|
+
# colors filled with black as default
|
|
273
|
+
for i in range(64):
|
|
274
|
+
offset = self._COLORS_OFFSET_IN_PAYLOAD + i * 8
|
|
275
|
+
struct.pack_into("<HHHH", payload, offset, 0, 0, 0, 3500)
|
|
276
|
+
|
|
277
|
+
# Combine header + payload
|
|
278
|
+
packet = header + payload
|
|
279
|
+
|
|
280
|
+
templates.append(
|
|
281
|
+
PacketTemplate(
|
|
282
|
+
data=packet,
|
|
283
|
+
color_offset=HEADER_SIZE + self._COLORS_OFFSET_IN_PAYLOAD,
|
|
284
|
+
color_count=min(self._pixels_per_tile, 64),
|
|
285
|
+
hsbk_start=tile_idx * self._pixels_per_tile,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return templates
|
|
290
|
+
|
|
291
|
+
def _create_large_tile_templates(
|
|
292
|
+
self, source: int, target: bytes
|
|
293
|
+
) -> list[PacketTemplate]:
|
|
294
|
+
"""Create templates for large tiles (>64 pixels each)."""
|
|
295
|
+
templates: list[PacketTemplate] = []
|
|
296
|
+
|
|
297
|
+
for tile_idx in range(self._tile_count):
|
|
298
|
+
tile_pixel_start = tile_idx * self._pixels_per_tile
|
|
299
|
+
|
|
300
|
+
# Create Set64 packets for this tile
|
|
301
|
+
for pkt_idx in range(self._packets_per_tile):
|
|
302
|
+
color_start = pkt_idx * self._MAX_COLORS_PER_PACKET
|
|
303
|
+
color_end = min(
|
|
304
|
+
color_start + self._MAX_COLORS_PER_PACKET,
|
|
305
|
+
self._pixels_per_tile,
|
|
306
|
+
)
|
|
307
|
+
color_count = color_end - color_start
|
|
308
|
+
|
|
309
|
+
if color_count == 0: # pragma: no cover
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Calculate y offset for this chunk
|
|
313
|
+
y_offset = pkt_idx * self._rows_per_packet
|
|
314
|
+
|
|
315
|
+
# Build header
|
|
316
|
+
header = _build_header(
|
|
317
|
+
self.SET64_PKT_TYPE, source, target, self._SET64_PAYLOAD_SIZE
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Build payload
|
|
321
|
+
payload = bytearray(self._SET64_PAYLOAD_SIZE)
|
|
322
|
+
payload[0] = tile_idx # tile_index
|
|
323
|
+
payload[1] = 1 # length
|
|
324
|
+
# TileBufferRect: fb_index=1 (temp), x=0, y=y_offset, width
|
|
325
|
+
struct.pack_into("<BBBB", payload, 2, 1, 0, y_offset, self._tile_width)
|
|
326
|
+
# duration = 0
|
|
327
|
+
struct.pack_into("<I", payload, 6, 0)
|
|
328
|
+
# colors filled with black as default
|
|
329
|
+
for i in range(64):
|
|
330
|
+
offset = self._COLORS_OFFSET_IN_PAYLOAD + i * 8
|
|
331
|
+
struct.pack_into("<HHHH", payload, offset, 0, 0, 0, 3500)
|
|
332
|
+
|
|
333
|
+
packet = header + payload
|
|
334
|
+
|
|
335
|
+
templates.append(
|
|
336
|
+
PacketTemplate(
|
|
337
|
+
data=packet,
|
|
338
|
+
color_offset=HEADER_SIZE + self._COLORS_OFFSET_IN_PAYLOAD,
|
|
339
|
+
color_count=color_count,
|
|
340
|
+
hsbk_start=tile_pixel_start + color_start,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Create CopyFrameBuffer packet for this tile
|
|
345
|
+
header = _build_header(
|
|
346
|
+
self.COPY_FRAME_BUFFER_PKT_TYPE,
|
|
347
|
+
source,
|
|
348
|
+
target,
|
|
349
|
+
self._COPY_FB_PAYLOAD_SIZE,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
payload = bytearray(self._COPY_FB_PAYLOAD_SIZE)
|
|
353
|
+
payload[0] = tile_idx # tile_index
|
|
354
|
+
payload[1] = 1 # length
|
|
355
|
+
payload[2] = 1 # src_fb_index (temp buffer)
|
|
356
|
+
payload[3] = 0 # dst_fb_index (display)
|
|
357
|
+
struct.pack_into("<BBBB", payload, 4, 0, 0, 0, 0) # src/dst x,y
|
|
358
|
+
payload[8] = self._tile_width
|
|
359
|
+
payload[9] = self._tile_height
|
|
360
|
+
struct.pack_into("<I", payload, 10, 0) # duration = 0
|
|
361
|
+
payload[14] = 0 # reserved
|
|
362
|
+
|
|
363
|
+
packet = header + payload
|
|
364
|
+
|
|
365
|
+
# CopyFrameBuffer has no colors to update
|
|
366
|
+
templates.append(
|
|
367
|
+
PacketTemplate(
|
|
368
|
+
data=packet,
|
|
369
|
+
color_offset=0, # No colors
|
|
370
|
+
color_count=0,
|
|
371
|
+
hsbk_start=0,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return templates
|
|
376
|
+
|
|
377
|
+
def update_colors(
|
|
378
|
+
self, templates: list[PacketTemplate], hsbk: list[tuple[int, int, int, int]]
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Update color data in prebaked templates.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
templates: Prebaked packet templates
|
|
384
|
+
hsbk: Protocol-ready HSBK data for all pixels
|
|
385
|
+
"""
|
|
386
|
+
for tmpl in templates:
|
|
387
|
+
if tmpl.color_count == 0:
|
|
388
|
+
continue # Skip CopyFrameBuffer packets
|
|
389
|
+
|
|
390
|
+
for i in range(tmpl.color_count):
|
|
391
|
+
h, s, b, k = hsbk[tmpl.hsbk_start + i]
|
|
392
|
+
offset = tmpl.color_offset + i * 8
|
|
393
|
+
struct.pack_into("<HHHH", tmpl.data, offset, h, s, b, k)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class MultiZonePacketGenerator(PacketGenerator):
|
|
397
|
+
"""Packet generator for MultiZoneLight devices with extended multizone.
|
|
398
|
+
|
|
399
|
+
Uses SetExtendedColorZones packets (up to 82 zones each). For devices
|
|
400
|
+
with >82 zones, multiple packets are generated.
|
|
401
|
+
|
|
402
|
+
SetExtendedColorZones Payload Layout (664 bytes):
|
|
403
|
+
- Offset 0-3: duration (uint32)
|
|
404
|
+
- Offset 4: apply (uint8, 1 = APPLY)
|
|
405
|
+
- Offset 5-6: zone_index (uint16)
|
|
406
|
+
- Offset 7: colors_count (uint8)
|
|
407
|
+
- Offset 8-663: colors (82 x HSBK, each 8 bytes)
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
SET_EXTENDED_COLOR_ZONES_PKT_TYPE: ClassVar[int] = 510
|
|
411
|
+
|
|
412
|
+
_PAYLOAD_SIZE: ClassVar[int] = 664
|
|
413
|
+
_COLORS_OFFSET_IN_PAYLOAD: ClassVar[int] = 8
|
|
414
|
+
_MAX_ZONES_PER_PACKET: ClassVar[int] = 82
|
|
415
|
+
|
|
416
|
+
def __init__(self, zone_count: int) -> None:
|
|
417
|
+
"""Initialize multizone packet generator.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
zone_count: Total number of zones on the device
|
|
421
|
+
"""
|
|
422
|
+
self._zone_count = zone_count
|
|
423
|
+
self._packets_needed = (
|
|
424
|
+
zone_count + self._MAX_ZONES_PER_PACKET - 1
|
|
425
|
+
) // self._MAX_ZONES_PER_PACKET
|
|
426
|
+
|
|
427
|
+
def pixel_count(self) -> int:
|
|
428
|
+
"""Get total zone count."""
|
|
429
|
+
return self._zone_count
|
|
430
|
+
|
|
431
|
+
def create_templates(self, source: int, target: bytes) -> list[PacketTemplate]:
|
|
432
|
+
"""Create prebaked packet templates for all zones.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
source: Client source ID
|
|
436
|
+
target: 6-byte device serial
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
List of PacketTemplate with complete prebaked packets
|
|
440
|
+
"""
|
|
441
|
+
templates: list[PacketTemplate] = []
|
|
442
|
+
|
|
443
|
+
for pkt_idx in range(self._packets_needed):
|
|
444
|
+
zone_start = pkt_idx * self._MAX_ZONES_PER_PACKET
|
|
445
|
+
zone_end = min(zone_start + self._MAX_ZONES_PER_PACKET, self._zone_count)
|
|
446
|
+
zone_count = zone_end - zone_start
|
|
447
|
+
|
|
448
|
+
# Build header
|
|
449
|
+
header = _build_header(
|
|
450
|
+
self.SET_EXTENDED_COLOR_ZONES_PKT_TYPE,
|
|
451
|
+
source,
|
|
452
|
+
target,
|
|
453
|
+
self._PAYLOAD_SIZE,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Build payload
|
|
457
|
+
payload = bytearray(self._PAYLOAD_SIZE)
|
|
458
|
+
# duration = 0
|
|
459
|
+
struct.pack_into("<I", payload, 0, 0)
|
|
460
|
+
# apply = 1 (APPLY)
|
|
461
|
+
payload[4] = 1
|
|
462
|
+
# zone_index
|
|
463
|
+
struct.pack_into("<H", payload, 5, zone_start)
|
|
464
|
+
# colors_count
|
|
465
|
+
payload[7] = zone_count
|
|
466
|
+
# colors filled with black as default
|
|
467
|
+
for i in range(82):
|
|
468
|
+
offset = self._COLORS_OFFSET_IN_PAYLOAD + i * 8
|
|
469
|
+
struct.pack_into("<HHHH", payload, offset, 0, 0, 0, 3500)
|
|
470
|
+
|
|
471
|
+
packet = header + payload
|
|
472
|
+
|
|
473
|
+
templates.append(
|
|
474
|
+
PacketTemplate(
|
|
475
|
+
data=packet,
|
|
476
|
+
color_offset=HEADER_SIZE + self._COLORS_OFFSET_IN_PAYLOAD,
|
|
477
|
+
color_count=zone_count,
|
|
478
|
+
hsbk_start=zone_start,
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return templates
|
|
483
|
+
|
|
484
|
+
def update_colors(
|
|
485
|
+
self, templates: list[PacketTemplate], hsbk: list[tuple[int, int, int, int]]
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Update color data in prebaked templates.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
templates: Prebaked packet templates
|
|
491
|
+
hsbk: Protocol-ready HSBK data for all zones
|
|
492
|
+
"""
|
|
493
|
+
for tmpl in templates:
|
|
494
|
+
for i in range(tmpl.color_count):
|
|
495
|
+
h, s, b, k = hsbk[tmpl.hsbk_start + i]
|
|
496
|
+
offset = tmpl.color_offset + i * 8
|
|
497
|
+
struct.pack_into("<HHHH", tmpl.data, offset, h, s, b, k)
|
lifx/api.py
CHANGED
|
@@ -52,10 +52,6 @@ class LocationGrouping:
|
|
|
52
52
|
devices: list[Device]
|
|
53
53
|
updated_at: int # Most recent updated_at from all devices
|
|
54
54
|
|
|
55
|
-
def to_device_group(self) -> DeviceGroup:
|
|
56
|
-
"""Convert to DeviceGroup for batch operations."""
|
|
57
|
-
return DeviceGroup(self.devices)
|
|
58
|
-
|
|
59
55
|
|
|
60
56
|
@dataclass
|
|
61
57
|
class GroupGrouping:
|
|
@@ -66,10 +62,6 @@ class GroupGrouping:
|
|
|
66
62
|
devices: list[Device]
|
|
67
63
|
updated_at: int
|
|
68
64
|
|
|
69
|
-
def to_device_group(self) -> DeviceGroup:
|
|
70
|
-
"""Convert to DeviceGroup for batch operations."""
|
|
71
|
-
return DeviceGroup(self.devices)
|
|
72
|
-
|
|
73
65
|
|
|
74
66
|
class DeviceGroup:
|
|
75
67
|
"""A group of devices for batch operations.
|
lifx/const.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# lifx-async constants
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
3
5
|
import uuid
|
|
4
6
|
from typing import Final
|
|
5
7
|
|
|
@@ -109,3 +111,19 @@ PROTOCOL_URL: Final[str] = (
|
|
|
109
111
|
PRODUCTS_URL: Final[str] = (
|
|
110
112
|
"https://raw.githubusercontent.com/LIFX/products/refs/heads/master/products.json"
|
|
111
113
|
)
|
|
114
|
+
|
|
115
|
+
# ============================================================================
|
|
116
|
+
# Python Version Compatibility
|
|
117
|
+
# ============================================================================
|
|
118
|
+
|
|
119
|
+
# On Python 3.10, asyncio.wait_for() raises asyncio.TimeoutError which is NOT
|
|
120
|
+
# a subclass of the built-in TimeoutError. In Python 3.11+, they are unified.
|
|
121
|
+
# Use this tuple with `except TIMEOUT_ERRORS:` to catch timeouts from asyncio
|
|
122
|
+
# operations on all supported Python versions.
|
|
123
|
+
if sys.version_info < (3, 11):
|
|
124
|
+
TIMEOUT_ERRORS: Final[tuple[type[BaseException], ...]] = (
|
|
125
|
+
TimeoutError,
|
|
126
|
+
asyncio.TimeoutError,
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
TIMEOUT_ERRORS: Final[tuple[type[BaseException], ...]] = (TimeoutError,)
|
lifx/devices/ceiling.py
CHANGED
|
@@ -26,6 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
from typing import Any, cast
|
|
27
27
|
|
|
28
28
|
from lifx.color import HSBK
|
|
29
|
+
from lifx.const import DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT, LIFX_UDP_PORT
|
|
29
30
|
from lifx.devices.matrix import MatrixLight, MatrixLightState
|
|
30
31
|
from lifx.exceptions import LifxError
|
|
31
32
|
from lifx.products import get_ceiling_layout, is_ceiling_product
|
|
@@ -142,9 +143,9 @@ class CeilingLight(MatrixLight):
|
|
|
142
143
|
self,
|
|
143
144
|
serial: str,
|
|
144
145
|
ip: str,
|
|
145
|
-
port: int =
|
|
146
|
-
timeout: float =
|
|
147
|
-
max_retries: int =
|
|
146
|
+
port: int = LIFX_UDP_PORT,
|
|
147
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
148
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
148
149
|
state_file: str | None = None,
|
|
149
150
|
):
|
|
150
151
|
"""Initialize CeilingLight.
|
|
@@ -154,9 +155,7 @@ class CeilingLight(MatrixLight):
|
|
|
154
155
|
ip: Device IP address
|
|
155
156
|
port: Device UDP port (default: 56700)
|
|
156
157
|
timeout: Overall timeout for network requests in seconds
|
|
157
|
-
(default: 0.5)
|
|
158
158
|
max_retries: Maximum number of retry attempts for network requests
|
|
159
|
-
(default: 3)
|
|
160
159
|
state_file: Optional path to JSON file for state persistence
|
|
161
160
|
|
|
162
161
|
Raises:
|
|
@@ -262,10 +261,10 @@ class CeilingLight(MatrixLight):
|
|
|
262
261
|
async def from_ip(
|
|
263
262
|
cls,
|
|
264
263
|
ip: str,
|
|
265
|
-
port: int =
|
|
264
|
+
port: int = LIFX_UDP_PORT,
|
|
266
265
|
serial: str | None = None,
|
|
267
|
-
timeout: float =
|
|
268
|
-
max_retries: int =
|
|
266
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
267
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
269
268
|
*,
|
|
270
269
|
state_file: str | None = None,
|
|
271
270
|
) -> CeilingLight:
|
lifx/devices/matrix.py
CHANGED
|
@@ -668,16 +668,7 @@ class MatrixLight(Light):
|
|
|
668
668
|
)
|
|
669
669
|
|
|
670
670
|
# Convert HSBK colors to protocol format
|
|
671
|
-
proto_colors = []
|
|
672
|
-
for color in colors:
|
|
673
|
-
proto_colors.append(
|
|
674
|
-
LightHsbk(
|
|
675
|
-
hue=int(color.hue / 360 * 65535),
|
|
676
|
-
saturation=int(color.saturation * 65535),
|
|
677
|
-
brightness=int(color.brightness * 65535),
|
|
678
|
-
kelvin=color.kelvin,
|
|
679
|
-
)
|
|
680
|
-
)
|
|
671
|
+
proto_colors = [color.to_protocol() for color in colors]
|
|
681
672
|
|
|
682
673
|
# Pad to 64 colors if needed
|
|
683
674
|
while len(proto_colors) < 64:
|
|
@@ -1004,15 +995,7 @@ class MatrixLight(Light):
|
|
|
1004
995
|
|
|
1005
996
|
if effect.palette is not None:
|
|
1006
997
|
palette_count = len(effect.palette)
|
|
1007
|
-
for color in effect.palette
|
|
1008
|
-
proto_palette.append(
|
|
1009
|
-
LightHsbk(
|
|
1010
|
-
hue=int(color.hue / 360 * 65535),
|
|
1011
|
-
saturation=int(color.saturation * 65535),
|
|
1012
|
-
brightness=int(color.brightness * 65535),
|
|
1013
|
-
kelvin=color.kelvin,
|
|
1014
|
-
)
|
|
1015
|
-
)
|
|
998
|
+
proto_palette = [color.to_protocol() for color in effect.palette]
|
|
1016
999
|
|
|
1017
1000
|
# Pad palette to 16 colors (protocol requirement)
|
|
1018
1001
|
while len(proto_palette) < 16:
|
lifx/effects/colorloop.py
CHANGED
|
@@ -11,7 +11,7 @@ import random
|
|
|
11
11
|
from typing import TYPE_CHECKING
|
|
12
12
|
|
|
13
13
|
from lifx.color import HSBK
|
|
14
|
-
from lifx.const import KELVIN_NEUTRAL
|
|
14
|
+
from lifx.const import KELVIN_NEUTRAL, TIMEOUT_ERRORS
|
|
15
15
|
from lifx.effects.base import LIFXEffect
|
|
16
16
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
@@ -172,7 +172,7 @@ class EffectColorloop(LIFXEffect):
|
|
|
172
172
|
self._stop_event.wait(), timeout=iteration_period
|
|
173
173
|
)
|
|
174
174
|
break # Stop event was set
|
|
175
|
-
except
|
|
175
|
+
except TIMEOUT_ERRORS:
|
|
176
176
|
pass # Normal - continue to next iteration
|
|
177
177
|
|
|
178
178
|
iteration += 1
|