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.
@@ -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 = 56700, # LIFX_UDP_PORT
146
- timeout: float = 0.5, # DEFAULT_REQUEST_TIMEOUT
147
- max_retries: int = 3, # DEFAULT_MAX_RETRIES
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 = 56700, # LIFX_UDP_PORT
264
+ port: int = LIFX_UDP_PORT,
266
265
  serial: str | None = None,
267
- timeout: float = 0.5, # DEFAULT_REQUEST_TIMEOUT
268
- max_retries: int = 3, # DEFAULT_MAX_RETRIES
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 TimeoutError:
175
+ except TIMEOUT_ERRORS:
176
176
  pass # Normal - continue to next iteration
177
177
 
178
178
  iteration += 1