lifx-async 4.4.0__py3-none-any.whl → 4.5.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/devices/__init__.py CHANGED
@@ -10,6 +10,7 @@ from lifx.devices.base import (
10
10
  FirmwareInfo,
11
11
  WifiInfo,
12
12
  )
13
+ from lifx.devices.ceiling import CeilingLight
13
14
  from lifx.devices.hev import HevLight, HevLightState
14
15
  from lifx.devices.infrared import InfraredLight, InfraredLightState
15
16
  from lifx.devices.light import Light, LightState
@@ -17,6 +18,7 @@ from lifx.devices.matrix import MatrixEffect, MatrixLight, MatrixLightState, Til
17
18
  from lifx.devices.multizone import MultiZoneEffect, MultiZoneLight, MultiZoneLightState
18
19
 
19
20
  __all__ = [
21
+ "CeilingLight",
20
22
  "CollectionInfo",
21
23
  "Device",
22
24
  "DeviceInfo",
@@ -0,0 +1,784 @@
1
+ """LIFX Ceiling Light Device.
2
+
3
+ This module provides the CeilingLight class for controlling LIFX Ceiling lights with
4
+ independent uplight and downlight component control.
5
+
6
+ Terminology:
7
+ - Zone: Individual HSBK pixel in the matrix (indexed 0-63 or 0-127)
8
+ - Component: Logical grouping of zones:
9
+ - Uplight Component: Single zone for ambient lighting (zone 63 or 127)
10
+ - Downlight Component: Multiple zones for main illumination (zones 0-62 or 0-126)
11
+
12
+ Product IDs:
13
+ - 176: Ceiling (US) - 8x8 matrix
14
+ - 177: Ceiling (Intl) - 8x8 matrix
15
+ - 201: Ceiling Capsule (US) - 16x8 matrix
16
+ - 202: Ceiling Capsule (Intl) - 16x8 matrix
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING
25
+
26
+ from lifx.color import HSBK
27
+ from lifx.devices.matrix import MatrixLight
28
+ from lifx.exceptions import LifxError
29
+ from lifx.products import get_ceiling_layout, is_ceiling_product
30
+
31
+ if TYPE_CHECKING:
32
+ pass
33
+
34
+ _LOGGER = logging.getLogger(__name__)
35
+
36
+
37
+ class CeilingLight(MatrixLight):
38
+ """LIFX Ceiling Light with independent uplight and downlight control.
39
+
40
+ CeilingLight extends MatrixLight to provide semantic control over uplight and
41
+ downlight components while maintaining full backward compatibility with the
42
+ MatrixLight API.
43
+
44
+ The uplight component is the last zone in the matrix, and the downlight component
45
+ consists of all other zones.
46
+
47
+ Example:
48
+ ```python
49
+ from lifx.devices import CeilingLight
50
+ from lifx.color import HSBK
51
+
52
+ async with await CeilingLight.from_ip("192.168.1.100") as ceiling:
53
+ # Independent component control
54
+ await ceiling.set_downlight_colors(HSBK(hue=0, sat=0, bri=1.0, kelvin=3500))
55
+ await ceiling.set_uplight_color(HSBK(hue=30, sat=0.2, bri=0.3, kelvin=2700))
56
+
57
+ # Turn components on/off
58
+ await ceiling.turn_downlight_on()
59
+ await ceiling.turn_uplight_off()
60
+
61
+ # Check component state
62
+ if ceiling.uplight_is_on:
63
+ print("Uplight is on")
64
+ ```
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ serial: str,
70
+ ip: str,
71
+ port: int = 56700, # LIFX_UDP_PORT
72
+ timeout: float = 0.5, # DEFAULT_REQUEST_TIMEOUT
73
+ max_retries: int = 3, # DEFAULT_MAX_RETRIES
74
+ state_file: str | None = None,
75
+ ):
76
+ """Initialize CeilingLight.
77
+
78
+ Args:
79
+ serial: Device serial number
80
+ ip: Device IP address
81
+ port: Device UDP port (default: 56700)
82
+ timeout: Overall timeout for network requests in seconds
83
+ (default: 0.5)
84
+ max_retries: Maximum number of retry attempts for network requests
85
+ (default: 3)
86
+ state_file: Optional path to JSON file for state persistence
87
+
88
+ Raises:
89
+ LifxError: If device is not a supported Ceiling product
90
+ """
91
+ super().__init__(serial, ip, port, timeout, max_retries)
92
+ self._state_file = state_file
93
+ self._stored_uplight_state: HSBK | None = None
94
+ self._stored_downlight_state: list[HSBK] | None = None
95
+ self._last_uplight_color: HSBK | None = None
96
+ self._last_downlight_colors: list[HSBK] | None = None
97
+
98
+ async def __aenter__(self) -> CeilingLight:
99
+ """Async context manager entry."""
100
+ await super().__aenter__()
101
+
102
+ # Validate product ID after version is fetched
103
+ if self.version and not is_ceiling_product(self.version.product):
104
+ raise LifxError(
105
+ f"Product ID {self.version.product} is not a supported Ceiling light."
106
+ )
107
+
108
+ # Load state from disk if state_file is provided
109
+ if self._state_file:
110
+ self._load_state_from_file()
111
+
112
+ return self
113
+
114
+ @classmethod
115
+ async def from_ip(
116
+ cls,
117
+ ip: str,
118
+ port: int = 56700, # LIFX_UDP_PORT
119
+ serial: str | None = None,
120
+ timeout: float = 0.5, # DEFAULT_REQUEST_TIMEOUT
121
+ max_retries: int = 3, # DEFAULT_MAX_RETRIES
122
+ *,
123
+ state_file: str | None = None,
124
+ ) -> CeilingLight:
125
+ """Create CeilingLight from IP address.
126
+
127
+ Args:
128
+ ip: Device IP address
129
+ port: Port number (default LIFX_UDP_PORT)
130
+ serial: Serial number as 12-digit hex string
131
+ timeout: Request timeout for this device instance
132
+ max_retries: Maximum number of retries for requests
133
+ state_file: Optional path to JSON file for state persistence
134
+
135
+ Returns:
136
+ CeilingLight instance
137
+
138
+ Raises:
139
+ LifxDeviceNotFoundError: Device not found at IP
140
+ LifxTimeoutError: Device did not respond
141
+ LifxError: Device is not a supported Ceiling product
142
+ """
143
+ # Use parent class factory method
144
+ device = await super().from_ip(ip, port, serial, timeout, max_retries)
145
+ # Type cast to CeilingLight and set state_file
146
+ ceiling = CeilingLight(device.serial, device.ip)
147
+ ceiling._state_file = state_file
148
+ ceiling.connection = device.connection
149
+ return ceiling
150
+
151
+ @property
152
+ def uplight_zone(self) -> int:
153
+ """Zone index of the uplight component.
154
+
155
+ Returns:
156
+ Zone index (63 for standard Ceiling, 127 for Capsule)
157
+
158
+ Raises:
159
+ LifxError: If device version is not available or not a Ceiling product
160
+ """
161
+ if not self.version:
162
+ raise LifxError("Device version not available. Use async context manager.")
163
+
164
+ layout = get_ceiling_layout(self.version.product)
165
+ if not layout:
166
+ raise LifxError(f"Product ID {self.version.product} is not a Ceiling light")
167
+
168
+ return layout.uplight_zone
169
+
170
+ @property
171
+ def downlight_zones(self) -> slice:
172
+ """Slice representing the downlight component zones.
173
+
174
+ Returns:
175
+ Slice object (slice(0, 63) for standard, slice(0, 127) for Capsule)
176
+
177
+ Raises:
178
+ LifxError: If device version is not available or not a Ceiling product
179
+ """
180
+ if not self.version:
181
+ raise LifxError("Device version not available. Use async context manager.")
182
+
183
+ layout = get_ceiling_layout(self.version.product)
184
+ if not layout:
185
+ raise LifxError(f"Product ID {self.version.product} is not a Ceiling light")
186
+
187
+ return layout.downlight_zones
188
+
189
+ @property
190
+ def uplight_is_on(self) -> bool:
191
+ """True if uplight component is currently on.
192
+
193
+ Calculated as: power_level > 0 AND uplight brightness > 0
194
+
195
+ Note:
196
+ Requires recent data from device. Call get_uplight_color() or
197
+ get_power() to refresh cached values before checking this property.
198
+
199
+ Returns:
200
+ True if uplight component is on, False otherwise
201
+ """
202
+ if self._state is None or self._state.power == 0:
203
+ return False
204
+
205
+ if self._last_uplight_color is None:
206
+ return False
207
+
208
+ return self._last_uplight_color.brightness > 0
209
+
210
+ @property
211
+ def downlight_is_on(self) -> bool:
212
+ """True if downlight component is currently on.
213
+
214
+ Calculated as: power_level > 0 AND NOT all downlight zones have brightness == 0
215
+
216
+ Note:
217
+ Requires recent data from device. Call get_downlight_colors() or
218
+ get_power() to refresh cached values before checking this property.
219
+
220
+ Returns:
221
+ True if downlight component is on, False otherwise
222
+ """
223
+ if self._state is None or self._state.power == 0:
224
+ return False
225
+
226
+ if self._last_downlight_colors is None:
227
+ return False
228
+
229
+ # Downlight is on if any downlight zone has a brightness > 0
230
+ return any(c.brightness > 0 for c in self._last_downlight_colors)
231
+
232
+ async def get_uplight_color(self) -> HSBK:
233
+ """Get current uplight component color from device.
234
+
235
+ Returns:
236
+ HSBK color of uplight zone
237
+
238
+ Raises:
239
+ LifxTimeoutError: Device did not respond
240
+ """
241
+ # Get all colors from tile
242
+ all_colors = await self.get_all_tile_colors()
243
+ tile_colors = all_colors[0] # First tile
244
+
245
+ # Extract uplight zone
246
+ uplight_color = tile_colors[self.uplight_zone]
247
+
248
+ # Cache for is_on property
249
+ self._last_uplight_color = uplight_color
250
+
251
+ return uplight_color
252
+
253
+ async def get_downlight_colors(self) -> list[HSBK]:
254
+ """Get current downlight component colors from device.
255
+
256
+ Returns:
257
+ List of HSBK colors for each downlight zone (63 or 127 zones)
258
+
259
+ Raises:
260
+ LifxTimeoutError: Device did not respond
261
+ """
262
+ # Get all colors from tile
263
+ all_colors = await self.get_all_tile_colors()
264
+ tile_colors = all_colors[0] # First tile
265
+
266
+ # Extract downlight zones
267
+ downlight_colors = tile_colors[self.downlight_zones]
268
+
269
+ # Cache for is_on property
270
+ self._last_downlight_colors = downlight_colors
271
+
272
+ return downlight_colors
273
+
274
+ async def set_uplight_color(self, color: HSBK, duration: float = 0.0) -> None:
275
+ """Set uplight component color.
276
+
277
+ Args:
278
+ color: HSBK color to set
279
+ duration: Transition duration in seconds (default 0.0)
280
+
281
+ Raises:
282
+ ValueError: If color.brightness == 0 (use turn_uplight_off instead)
283
+ LifxTimeoutError: Device did not respond
284
+
285
+ Note:
286
+ Also updates stored state for future restoration.
287
+ """
288
+ if color.brightness == 0:
289
+ raise ValueError(
290
+ "Cannot set uplight color with brightness=0. "
291
+ "Use turn_uplight_off() instead."
292
+ )
293
+
294
+ # Get current colors for all zones
295
+ all_colors = await self.get_all_tile_colors()
296
+ tile_colors = all_colors[0]
297
+
298
+ # Update uplight zone
299
+ tile_colors[self.uplight_zone] = color
300
+
301
+ # Set all colors back (duration in milliseconds for set_matrix_colors)
302
+ await self.set_matrix_colors(0, tile_colors, duration=int(duration * 1000))
303
+
304
+ # Store state
305
+ self._stored_uplight_state = color
306
+ self._last_uplight_color = color
307
+
308
+ # Persist if enabled
309
+ if self._state_file:
310
+ self._save_state_to_file()
311
+
312
+ async def set_downlight_colors(
313
+ self, colors: HSBK | list[HSBK], duration: float = 0.0
314
+ ) -> None:
315
+ """Set downlight component colors.
316
+
317
+ Args:
318
+ colors: Either:
319
+ - Single HSBK: sets all downlight zones to same color
320
+ - List[HSBK]: sets each zone individually (must match zone count)
321
+ duration: Transition duration in seconds (default 0.0)
322
+
323
+ Raises:
324
+ ValueError: If any color.brightness == 0 (use turn_downlight_off instead)
325
+ ValueError: If list length doesn't match downlight zone count
326
+ LifxTimeoutError: Device did not respond
327
+
328
+ Note:
329
+ Also updates stored state for future restoration.
330
+ """
331
+ # Validate and normalize colors
332
+ if isinstance(colors, HSBK):
333
+ if colors.brightness == 0:
334
+ raise ValueError(
335
+ "Cannot set downlight color with brightness=0. "
336
+ "Use turn_downlight_off() instead."
337
+ )
338
+ downlight_colors = [colors] * len(range(*self.downlight_zones.indices(256)))
339
+ else:
340
+ if all(c.brightness == 0 for c in colors):
341
+ raise ValueError(
342
+ "Cannot set downlight colors with brightness=0. "
343
+ "Use turn_downlight_off() instead."
344
+ )
345
+
346
+ expected_count = len(range(*self.downlight_zones.indices(256)))
347
+ if len(colors) != expected_count:
348
+ raise ValueError(
349
+ f"Expected {expected_count} colors for downlight, got {len(colors)}"
350
+ )
351
+ downlight_colors = colors
352
+
353
+ # Get current colors for all zones
354
+ all_colors = await self.get_all_tile_colors()
355
+ tile_colors = all_colors[0]
356
+
357
+ # Update downlight zones
358
+ tile_colors[self.downlight_zones] = downlight_colors
359
+
360
+ # Set all colors back
361
+ await self.set_matrix_colors(0, tile_colors, duration=int(duration * 1000))
362
+
363
+ # Store state
364
+ self._stored_downlight_state = downlight_colors
365
+ self._last_downlight_colors = downlight_colors
366
+
367
+ # Persist if enabled
368
+ if self._state_file:
369
+ self._save_state_to_file()
370
+
371
+ async def turn_uplight_on(
372
+ self, color: HSBK | None = None, duration: float = 0.0
373
+ ) -> None:
374
+ """Turn uplight component on.
375
+
376
+ Args:
377
+ color: Optional HSBK color. If provided:
378
+ - Uses this color immediately
379
+ - Updates stored state
380
+ If None, uses brightness determination logic
381
+ duration: Transition duration in seconds (default 0.0)
382
+
383
+ Raises:
384
+ ValueError: If color.brightness == 0
385
+ LifxTimeoutError: Device did not respond
386
+ """
387
+ if color is not None:
388
+ if color.brightness == 0:
389
+ raise ValueError("Cannot turn on uplight with brightness=0")
390
+ await self.set_uplight_color(color, duration)
391
+ else:
392
+ # Determine color using priority logic
393
+ determined_color = await self._determine_uplight_brightness()
394
+ await self.set_uplight_color(determined_color, duration)
395
+
396
+ async def turn_uplight_off(
397
+ self, color: HSBK | None = None, duration: float = 0.0
398
+ ) -> None:
399
+ """Turn uplight component off.
400
+
401
+ Args:
402
+ color: Optional HSBK color to store for future turn_on.
403
+ If provided, stores this color (with brightness=0 on the device).
404
+ If None, stores current color from device before turning off.
405
+ duration: Transition duration in seconds (default 0.0)
406
+
407
+ Raises:
408
+ ValueError: If color.brightness == 0
409
+ LifxTimeoutError: Device did not respond
410
+
411
+ Note:
412
+ Sets uplight zone brightness to 0 on device while preserving H, S, K.
413
+ """
414
+ if color is not None:
415
+ if color.brightness == 0:
416
+ raise ValueError(
417
+ "Provided color cannot have brightness=0. "
418
+ "Omit the parameter to use current color."
419
+ )
420
+ # Store the provided color
421
+ self._stored_uplight_state = color
422
+ else:
423
+ # Get and store current color
424
+ current_color = await self.get_uplight_color()
425
+ self._stored_uplight_state = current_color
426
+
427
+ # Create color with brightness=0 for device
428
+ off_color = HSBK(
429
+ hue=self._stored_uplight_state.hue,
430
+ saturation=self._stored_uplight_state.saturation,
431
+ brightness=0.0,
432
+ kelvin=self._stored_uplight_state.kelvin,
433
+ )
434
+
435
+ # Get all colors and update uplight zone
436
+ all_colors = await self.get_all_tile_colors()
437
+ tile_colors = all_colors[0]
438
+ tile_colors[self.uplight_zone] = off_color
439
+ await self.set_matrix_colors(0, tile_colors, duration=int(duration * 1000))
440
+
441
+ # Update cache
442
+ self._last_uplight_color = off_color
443
+
444
+ # Persist if enabled
445
+ if self._state_file:
446
+ self._save_state_to_file()
447
+
448
+ async def turn_downlight_on(
449
+ self, colors: HSBK | list[HSBK] | None = None, duration: float = 0.0
450
+ ) -> None:
451
+ """Turn downlight component on.
452
+
453
+ Args:
454
+ colors: Optional colors. Can be:
455
+ - None: uses brightness determination logic
456
+ - Single HSBK: sets all downlight zones to same color
457
+ - List[HSBK]: sets each zone individually (must match zone count)
458
+ If provided, updates stored state.
459
+ duration: Transition duration in seconds (default 0.0)
460
+
461
+ Raises:
462
+ ValueError: If any color.brightness == 0
463
+ ValueError: If list length doesn't match downlight zone count
464
+ LifxTimeoutError: Device did not respond
465
+ """
466
+ if colors is not None:
467
+ await self.set_downlight_colors(colors, duration)
468
+ else:
469
+ # Determine colors using priority logic
470
+ determined_colors = await self._determine_downlight_brightness()
471
+ await self.set_downlight_colors(determined_colors, duration)
472
+
473
+ async def turn_downlight_off(
474
+ self, colors: HSBK | list[HSBK] | None = None, duration: float = 0.0
475
+ ) -> None:
476
+ """Turn downlight component off.
477
+
478
+ Args:
479
+ colors: Optional colors to store for future turn_on. Can be:
480
+ - None: stores current colors from device
481
+ - Single HSBK: stores this color for all zones
482
+ - List[HSBK]: stores individual colors (must match zone count)
483
+ If provided, stores these colors (with brightness=0 on device).
484
+ duration: Transition duration in seconds (default 0.0)
485
+
486
+ Raises:
487
+ ValueError: If any color.brightness == 0
488
+ ValueError: If list length doesn't match downlight zone count
489
+ LifxTimeoutError: Device did not respond
490
+
491
+ Note:
492
+ Sets all downlight zone brightness to 0 on device while preserving H, S, K.
493
+ """
494
+ expected_count = len(range(*self.downlight_zones.indices(256)))
495
+
496
+ if colors is not None:
497
+ # Validate and normalize provided colors
498
+ if isinstance(colors, HSBK):
499
+ if colors.brightness == 0:
500
+ raise ValueError(
501
+ "Provided color cannot have brightness=0. "
502
+ "Omit the parameter to use current colors."
503
+ )
504
+ colors_to_store = [colors] * expected_count
505
+ else:
506
+ if all(c.brightness == 0 for c in colors):
507
+ raise ValueError(
508
+ "Provided colors cannot have brightness=0. "
509
+ "Omit the parameter to use current colors."
510
+ )
511
+ if len(colors) != expected_count:
512
+ raise ValueError(
513
+ f"Expected {expected_count} colors for downlight, "
514
+ f"got {len(colors)}"
515
+ )
516
+ colors_to_store = colors
517
+
518
+ self._stored_downlight_state = colors_to_store
519
+ else:
520
+ # Get and store current colors
521
+ current_colors = await self.get_downlight_colors()
522
+ self._stored_downlight_state = current_colors
523
+
524
+ # Create colors with brightness=0 for device
525
+ off_colors = [
526
+ HSBK(
527
+ hue=c.hue,
528
+ saturation=c.saturation,
529
+ brightness=0.0,
530
+ kelvin=c.kelvin,
531
+ )
532
+ for c in self._stored_downlight_state
533
+ ]
534
+
535
+ # Get all colors and update downlight zones
536
+ all_colors = await self.get_all_tile_colors()
537
+ tile_colors = all_colors[0]
538
+ tile_colors[self.downlight_zones] = off_colors
539
+ await self.set_matrix_colors(0, tile_colors, duration=int(duration * 1000))
540
+
541
+ # Update cache
542
+ self._last_downlight_colors = off_colors
543
+
544
+ # Persist if enabled
545
+ if self._state_file:
546
+ self._save_state_to_file()
547
+
548
+ async def _determine_uplight_brightness(self) -> HSBK:
549
+ """Determine uplight brightness using priority logic.
550
+
551
+ Priority order:
552
+ 1. Stored state (if available)
553
+ 2. Infer from downlight average brightness
554
+ 3. Hardcoded default (0.8)
555
+
556
+ Returns:
557
+ HSBK color for uplight
558
+ """
559
+ # 1. Stored state
560
+ if self._stored_uplight_state is not None:
561
+ return self._stored_uplight_state
562
+
563
+ # Get current uplight color for H, S, K
564
+ current_uplight = await self.get_uplight_color()
565
+
566
+ # 2. Infer from downlight average brightness
567
+ try:
568
+ downlight_colors = await self.get_downlight_colors()
569
+ avg_brightness = sum(c.brightness for c in downlight_colors) / len(
570
+ downlight_colors
571
+ )
572
+
573
+ # Only use inferred brightness if it's > 0
574
+ # If all downlights are off (brightness=0), skip to default
575
+ if avg_brightness > 0:
576
+ return HSBK(
577
+ hue=current_uplight.hue,
578
+ saturation=current_uplight.saturation,
579
+ brightness=avg_brightness,
580
+ kelvin=current_uplight.kelvin,
581
+ )
582
+ except Exception: # nosec B110
583
+ # If inference fails, fall through to default
584
+ pass
585
+
586
+ # 3. Hardcoded default (0.8)
587
+ return HSBK(
588
+ hue=current_uplight.hue,
589
+ saturation=current_uplight.saturation,
590
+ brightness=0.8,
591
+ kelvin=current_uplight.kelvin,
592
+ )
593
+
594
+ async def _determine_downlight_brightness(self) -> list[HSBK]:
595
+ """Determine downlight brightness using priority logic.
596
+
597
+ Priority order:
598
+ 1. Stored state (if available)
599
+ 2. Infer from uplight brightness
600
+ 3. Hardcoded default (0.8)
601
+
602
+ Returns:
603
+ List of HSBK colors for downlight zones
604
+ """
605
+ # 1. Stored state
606
+ if self._stored_downlight_state is not None:
607
+ return self._stored_downlight_state
608
+
609
+ # Get current downlight colors for H, S, K
610
+ current_downlight = await self.get_downlight_colors()
611
+
612
+ # 2. Infer from uplight brightness
613
+ try:
614
+ uplight_color = await self.get_uplight_color()
615
+
616
+ # Only use inferred brightness if it's > 0
617
+ # If uplight is off (brightness=0), skip to default
618
+ if uplight_color.brightness > 0:
619
+ return [
620
+ HSBK(
621
+ hue=c.hue,
622
+ saturation=c.saturation,
623
+ brightness=uplight_color.brightness,
624
+ kelvin=c.kelvin,
625
+ )
626
+ for c in current_downlight
627
+ ]
628
+ except Exception: # nosec B110
629
+ # If inference fails, fall through to default
630
+ pass
631
+
632
+ # 3. Hardcoded default (0.8)
633
+ return [
634
+ HSBK(
635
+ hue=c.hue,
636
+ saturation=c.saturation,
637
+ brightness=0.8,
638
+ kelvin=c.kelvin,
639
+ )
640
+ for c in current_downlight
641
+ ]
642
+
643
+ def _is_stored_state_valid(
644
+ self, component: str, current: HSBK | list[HSBK]
645
+ ) -> bool:
646
+ """Check if stored state matches current (ignoring brightness).
647
+
648
+ Args:
649
+ component: Either "uplight" or "downlight"
650
+ current: Current color(s) from device
651
+
652
+ Returns:
653
+ True if stored state matches current (H, S, K), False otherwise
654
+ """
655
+ if component == "uplight":
656
+ if self._stored_uplight_state is None or not isinstance(current, HSBK):
657
+ return False
658
+
659
+ stored = self._stored_uplight_state
660
+ return (
661
+ stored.hue == current.hue
662
+ and stored.saturation == current.saturation
663
+ and stored.kelvin == current.kelvin
664
+ )
665
+
666
+ if component == "downlight":
667
+ if self._stored_downlight_state is None or not isinstance(current, list):
668
+ return False
669
+
670
+ if len(self._stored_downlight_state) != len(current):
671
+ return False
672
+
673
+ # Check if all zones match (H, S, K)
674
+ return all(
675
+ s.hue == c.hue and s.saturation == c.saturation and s.kelvin == c.kelvin
676
+ for s, c in zip(self._stored_downlight_state, current)
677
+ )
678
+
679
+ return False
680
+
681
+ def _load_state_from_file(self) -> None:
682
+ """Load state from JSON file.
683
+
684
+ Handles file not found and JSON errors gracefully.
685
+ """
686
+ if not self._state_file:
687
+ return
688
+
689
+ try:
690
+ state_path = Path(self._state_file).expanduser()
691
+ if not state_path.exists():
692
+ _LOGGER.debug("State file does not exist: %s", state_path)
693
+ return
694
+
695
+ with state_path.open("r") as f:
696
+ data = json.load(f)
697
+
698
+ # Get state for this device
699
+ device_state = data.get(self.serial)
700
+ if not device_state:
701
+ _LOGGER.debug("No state found for device %s", self.serial)
702
+ return
703
+
704
+ # Load uplight state
705
+ if "uplight" in device_state:
706
+ uplight_data = device_state["uplight"]
707
+ self._stored_uplight_state = HSBK(
708
+ hue=uplight_data["hue"],
709
+ saturation=uplight_data["saturation"],
710
+ brightness=uplight_data["brightness"],
711
+ kelvin=uplight_data["kelvin"],
712
+ )
713
+
714
+ # Load downlight state
715
+ if "downlight" in device_state:
716
+ downlight_data = device_state["downlight"]
717
+ self._stored_downlight_state = [
718
+ HSBK(
719
+ hue=c["hue"],
720
+ saturation=c["saturation"],
721
+ brightness=c["brightness"],
722
+ kelvin=c["kelvin"],
723
+ )
724
+ for c in downlight_data
725
+ ]
726
+
727
+ _LOGGER.debug("Loaded state from %s for device %s", state_path, self.serial)
728
+
729
+ except Exception as e:
730
+ _LOGGER.warning("Failed to load state from %s: %s", self._state_file, e)
731
+
732
+ def _save_state_to_file(self) -> None:
733
+ """Save state to JSON file.
734
+
735
+ Handles file I/O errors gracefully.
736
+ """
737
+ if not self._state_file:
738
+ return
739
+
740
+ try:
741
+ state_path = Path(self._state_file).expanduser()
742
+
743
+ # Load existing data or create new
744
+ if state_path.exists():
745
+ with state_path.open("r") as f:
746
+ data = json.load(f)
747
+ else:
748
+ data = {}
749
+
750
+ # Update state for this device
751
+ device_state = {}
752
+
753
+ if self._stored_uplight_state:
754
+ device_state["uplight"] = {
755
+ "hue": self._stored_uplight_state.hue,
756
+ "saturation": self._stored_uplight_state.saturation,
757
+ "brightness": self._stored_uplight_state.brightness,
758
+ "kelvin": self._stored_uplight_state.kelvin,
759
+ }
760
+
761
+ if self._stored_downlight_state:
762
+ device_state["downlight"] = [
763
+ {
764
+ "hue": c.hue,
765
+ "saturation": c.saturation,
766
+ "brightness": c.brightness,
767
+ "kelvin": c.kelvin,
768
+ }
769
+ for c in self._stored_downlight_state
770
+ ]
771
+
772
+ data[self.serial] = device_state
773
+
774
+ # Ensure directory exists
775
+ state_path.parent.mkdir(parents=True, exist_ok=True)
776
+
777
+ # Write to file
778
+ with state_path.open("w") as f:
779
+ json.dump(data, f, indent=2)
780
+
781
+ _LOGGER.debug("Saved state to %s for device %s", state_path, self.serial)
782
+
783
+ except Exception as e:
784
+ _LOGGER.warning("Failed to save state to %s: %s", self._state_file, e)
lifx/devices/matrix.py CHANGED
@@ -14,7 +14,6 @@ Terminology:
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import asyncio
18
17
  import logging
19
18
  import time
20
19
  from dataclasses import asdict, dataclass
@@ -544,6 +543,80 @@ class MatrixLight(Light):
544
543
 
545
544
  return result
546
545
 
546
+ async def get_all_tile_colors(self) -> list[list[HSBK]]:
547
+ """Get colors for all tiles in the chain.
548
+
549
+ Fetches colors from each tile in the device chain and returns them
550
+ as a list of color lists (one per tile). This is the matrix equivalent
551
+ of MultiZoneLight's get_all_color_zones().
552
+
553
+ For tiles with >64 zones (e.g., 16x8 Ceiling with 128 zones), makes
554
+ multiple Get64 requests to fetch all colors.
555
+
556
+ Always fetches from device. Tiles are queried sequentially to avoid
557
+ overwhelming the device with concurrent requests.
558
+
559
+ Returns:
560
+ List of color lists, one per tile. Each inner list contains
561
+ all colors for that tile (64 for 8x8 tiles, 128 for 16x8 Ceiling).
562
+
563
+ Raises:
564
+ LifxDeviceNotFoundError: If device is not connected
565
+ LifxTimeoutError: If device does not respond
566
+ LifxUnsupportedCommandError: If device doesn't support this command
567
+
568
+ Example:
569
+ ```python
570
+ # Get colors for all tiles
571
+ all_colors = await matrix.get_all_tile_colors()
572
+ print(f"Device has {len(all_colors)} tiles")
573
+ for i, tile_colors in enumerate(all_colors):
574
+ print(f"Tile {i}: {len(tile_colors)} colors")
575
+
576
+ # Flatten to single list if needed
577
+ flat_colors = [c for tile in all_colors for c in tile]
578
+ ```
579
+ """
580
+ # Get device chain (use cached if available)
581
+ if self._device_chain is None:
582
+ device_chain = await self.get_device_chain()
583
+ else:
584
+ device_chain = self._device_chain
585
+
586
+ # Fetch colors from each tile sequentially
587
+ all_colors: list[list[HSBK]] = []
588
+ for tile in device_chain:
589
+ tile_zone_count = tile.width * tile.height
590
+
591
+ if tile_zone_count <= 64:
592
+ # Single request for tiles with ≤64 zones
593
+ tile_colors = await self.get64(tile_index=tile.tile_index)
594
+ all_colors.append(tile_colors)
595
+ else:
596
+ # Multiple requests for tiles with >64 zones (e.g., 16x8 Ceiling)
597
+ # Split into multiple 64-zone requests by row
598
+ tile_colors = []
599
+ rows_per_request = 64 // tile.width # e.g., 64/16 = 4 rows
600
+
601
+ for y_offset in range(0, tile.height, rows_per_request):
602
+ chunk = await self.get64(
603
+ tile_index=tile.tile_index,
604
+ x=0,
605
+ y=y_offset,
606
+ width=tile.width,
607
+ )
608
+ tile_colors.extend(chunk)
609
+
610
+ all_colors.append(tile_colors)
611
+
612
+ # Update state if it exists (flatten for state storage)
613
+ if self._state is not None and hasattr(self._state, "tile_colors"):
614
+ flat_colors = [c for tile_colors in all_colors for c in tile_colors]
615
+ self._state.tile_colors = flat_colors
616
+ self._state.last_updated = time.time()
617
+
618
+ return all_colors
619
+
547
620
  async def set64(
548
621
  self,
549
622
  tile_index: int,
@@ -993,8 +1066,13 @@ class MatrixLight(Light):
993
1066
  canvas = Canvas()
994
1067
  for tile in tiles:
995
1068
  canvas.add_points_for_tile((int(tile.user_x), int(tile.user_y)), theme)
996
- canvas.shuffle_points()
997
- canvas.blur_by_distance()
1069
+
1070
+ # Shuffle and blur ONCE after all points are added
1071
+ # (Previously these were inside the loop, causing earlier tiles' points
1072
+ # to be shuffled/blurred multiple times, displacing them from their
1073
+ # intended positions and losing theme color variety)
1074
+ canvas.shuffle_points()
1075
+ canvas.blur_by_distance()
998
1076
 
999
1077
  # Create tile canvas and fill in gaps for smooth interpolation
1000
1078
  tile_canvas = Canvas()
@@ -1068,7 +1146,7 @@ class MatrixLight(Light):
1068
1146
  async def refresh_state(self) -> None:
1069
1147
  """Refresh matrix light state from hardware.
1070
1148
 
1071
- Fetches color, tiles, tile colors, and effect.
1149
+ Fetches color, tiles, tile colors for all tiles, and effect.
1072
1150
 
1073
1151
  Raises:
1074
1152
  RuntimeError: If state has not been initialized
@@ -1077,15 +1155,12 @@ class MatrixLight(Light):
1077
1155
  """
1078
1156
  await super().refresh_state()
1079
1157
 
1080
- # Fetch all matrix light state
1081
- async with asyncio.TaskGroup() as tg:
1082
- colors_task = tg.create_task(self.get64())
1083
- effect_task = tg.create_task(self.get_effect())
1158
+ # Fetch all matrix light state sequentially to avoid overwhelming device
1159
+ all_tile_colors = await self.get_all_tile_colors()
1160
+ effect = await self.get_effect()
1084
1161
 
1085
- tile_colors = colors_task.result()
1086
- effect = effect_task.result()
1087
-
1088
- self._state.tile_colors = tile_colors
1162
+ # Flatten tile colors for state storage
1163
+ self._state.tile_colors = [c for tile in all_tile_colors for c in tile]
1089
1164
  self._state.effect = effect.effect_type
1090
1165
 
1091
1166
  async def _initialize_state(self) -> MatrixLightState:
@@ -1103,24 +1178,24 @@ class MatrixLight(Light):
1103
1178
  """
1104
1179
  light_state = await super()._initialize_state()
1105
1180
 
1106
- async with asyncio.TaskGroup() as tg:
1107
- chain_task = tg.create_task(self.get_device_chain())
1108
- tile_colors_task = tg.create_task(self.get64())
1109
- effect_task = tg.create_task(self.get_effect())
1110
-
1111
- chain = chain_task.result()
1181
+ # Fetch matrix-specific state sequentially to avoid overwhelming device
1182
+ chain = await self.get_device_chain()
1112
1183
  tile_orientations = {
1113
1184
  index: tile.nearest_orientation for index, tile in enumerate(chain)
1114
1185
  }
1115
- tile_colors = tile_colors_task.result()
1116
- effect = effect_task.result()
1186
+ # get_all_tile_colors uses cached chain from above
1187
+ all_tile_colors = await self.get_all_tile_colors()
1188
+ effect = await self.get_effect()
1189
+
1190
+ # Flatten tile colors for state storage
1191
+ flat_tile_colors = [c for tile in all_tile_colors for c in tile]
1117
1192
 
1118
1193
  # Create state instance with matrix fields
1119
1194
  self._state = MatrixLightState.from_light_state(
1120
1195
  light_state,
1121
1196
  chain=chain,
1122
1197
  tile_orientations=tile_orientations,
1123
- tile_colors=tile_colors,
1198
+ tile_colors=flat_tile_colors,
1124
1199
  effect=effect.effect_type,
1125
1200
  )
1126
1201
 
lifx/network/discovery.py CHANGED
@@ -56,8 +56,8 @@ class DiscoveredDevice:
56
56
 
57
57
  Queries the device for its product ID and uses the product registry
58
58
  to instantiate the appropriate device class (Device, Light, HevLight,
59
- InfraredLight, MultiZoneLight, or MatrixLight) based on the product
60
- capabilities.
59
+ InfraredLight, MultiZoneLight, MatrixLight, or CeilingLight) based on
60
+ the product capabilities.
61
61
 
62
62
  This is the single source of truth for device type detection and
63
63
  instantiation across the library.
@@ -79,11 +79,13 @@ class DiscoveredDevice:
79
79
  ```
80
80
  """
81
81
  from lifx.devices.base import Device
82
+ from lifx.devices.ceiling import CeilingLight
82
83
  from lifx.devices.hev import HevLight
83
84
  from lifx.devices.infrared import InfraredLight
84
85
  from lifx.devices.light import Light
85
86
  from lifx.devices.matrix import MatrixLight
86
87
  from lifx.devices.multizone import MultiZoneLight
88
+ from lifx.products import is_ceiling_product
87
89
 
88
90
  kwargs = {
89
91
  "serial": self.serial,
@@ -100,6 +102,12 @@ class DiscoveredDevice:
100
102
  await temp_device._ensure_capabilities()
101
103
 
102
104
  if temp_device.capabilities:
105
+ # Check for Ceiling products first (before generic MatrixLight)
106
+ if temp_device.version and is_ceiling_product(
107
+ temp_device.version.product
108
+ ):
109
+ return CeilingLight(**kwargs)
110
+
103
111
  if temp_device.capabilities.has_matrix:
104
112
  return MatrixLight(**kwargs)
105
113
  if temp_device.capabilities.has_multizone:
lifx/products/__init__.py CHANGED
@@ -9,6 +9,11 @@ products.json specification.
9
9
  To update: run `uv run python -m lifx.products.generator`
10
10
  """
11
11
 
12
+ from lifx.products.quirks import (
13
+ CeilingComponentLayout,
14
+ get_ceiling_layout,
15
+ is_ceiling_product,
16
+ )
12
17
  from lifx.products.registry import (
13
18
  ProductCapability,
14
19
  ProductInfo,
@@ -19,10 +24,13 @@ from lifx.products.registry import (
19
24
  )
20
25
 
21
26
  __all__ = [
27
+ "CeilingComponentLayout",
22
28
  "ProductCapability",
23
29
  "ProductInfo",
24
30
  "ProductRegistry",
25
31
  "TemperatureRange",
32
+ "get_ceiling_layout",
26
33
  "get_product",
27
34
  "get_registry",
35
+ "is_ceiling_product",
28
36
  ]
@@ -0,0 +1,91 @@
1
+ """Product-specific quirks and metadata not available in products.json.
2
+
3
+ This module provides additional metadata for LIFX products that is not included
4
+ in the official products.json specification. These quirks are manually maintained
5
+ and should be updated as needed when new products are released or when LIFX adds
6
+ this information to products.json.
7
+
8
+ Note:
9
+ If LIFX adds any of this information to products.json in the future,
10
+ the generator should be updated to include it in the auto-generated registry,
11
+ and the corresponding quirk should be removed from this module.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+
19
+ @dataclass
20
+ class CeilingComponentLayout:
21
+ """Component layout for LIFX Ceiling lights.
22
+
23
+ Ceiling lights have two logical components:
24
+ - Uplight: Single zone for ambient/indirect lighting
25
+ - Downlight: Multiple zones for main illumination
26
+
27
+ Attributes:
28
+ width: Matrix width in zones
29
+ height: Matrix height in zones
30
+ uplight_zone: Zone index for the uplight component
31
+ downlight_zones: Slice representing downlight component zones
32
+ """
33
+
34
+ width: int
35
+ height: int
36
+ uplight_zone: int
37
+ downlight_zones: slice
38
+
39
+
40
+ # Ceiling product component layouts
41
+ # TODO: Remove once LIFX adds component layout metadata to products.json
42
+ CEILING_LAYOUTS: dict[int, CeilingComponentLayout] = {
43
+ 176: CeilingComponentLayout( # Ceiling (US)
44
+ width=8,
45
+ height=8,
46
+ uplight_zone=63,
47
+ downlight_zones=slice(0, 63),
48
+ ),
49
+ 177: CeilingComponentLayout( # Ceiling (Intl)
50
+ width=8,
51
+ height=8,
52
+ uplight_zone=63,
53
+ downlight_zones=slice(0, 63),
54
+ ),
55
+ 201: CeilingComponentLayout( # Ceiling Capsule (US)
56
+ width=16,
57
+ height=8,
58
+ uplight_zone=127,
59
+ downlight_zones=slice(0, 127),
60
+ ),
61
+ 202: CeilingComponentLayout( # Ceiling Capsule (Intl)
62
+ width=16,
63
+ height=8,
64
+ uplight_zone=127,
65
+ downlight_zones=slice(0, 127),
66
+ ),
67
+ }
68
+
69
+
70
+ def get_ceiling_layout(pid: int) -> CeilingComponentLayout | None:
71
+ """Get component layout for a Ceiling product.
72
+
73
+ Args:
74
+ pid: Product ID
75
+
76
+ Returns:
77
+ CeilingComponentLayout if product is a Ceiling light, None otherwise
78
+ """
79
+ return CEILING_LAYOUTS.get(pid)
80
+
81
+
82
+ def is_ceiling_product(pid: int) -> bool:
83
+ """Check if product ID is a Ceiling light.
84
+
85
+ Args:
86
+ pid: Product ID
87
+
88
+ Returns:
89
+ True if product is a Ceiling light
90
+ """
91
+ return pid in CEILING_LAYOUTS
lifx/theme/generators.py CHANGED
@@ -165,10 +165,16 @@ class MatrixGenerator:
165
165
  shuffled_theme = theme.shuffled()
166
166
  shuffled_theme.ensure_color()
167
167
 
168
+ # Add points for all tiles first
168
169
  for (left_x, top_y), (width, height) in self.coords_and_sizes:
169
170
  canvas.add_points_for_tile((left_x, top_y), shuffled_theme)
170
- canvas.shuffle_points()
171
- canvas.blur_by_distance()
171
+
172
+ # Shuffle and blur ONCE after all points are added
173
+ # (Previously these were inside the loop, causing earlier tiles' points
174
+ # to be shuffled/blurred multiple times, displacing them from their
175
+ # intended positions and losing theme color variety)
176
+ canvas.shuffle_points()
177
+ canvas.blur_by_distance()
172
178
 
173
179
  # Create tile canvas and fill gaps
174
180
  tile_canvas = Canvas()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 4.4.0
3
+ Version: 4.5.0
4
4
  Summary: A modern, type-safe, async Python library for controlling LIFX lights
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -4,12 +4,13 @@ lifx/color.py,sha256=wcmeeiBmOAjunInERNd6rslKvBEpV4vfjwwiZ8v7H8A,17877
4
4
  lifx/const.py,sha256=cf_O_3TqJjIBXF1tI35PkJ1JOhmy4tRt14PSa63pilA,3471
5
5
  lifx/exceptions.py,sha256=pikAMppLn7gXyjiQVWM_tSvXKNh-g366nG_UWyqpHhc,815
6
6
  lifx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- lifx/devices/__init__.py,sha256=vMNbhQKMpx3zD7EPbiejjDoAYTeT-q_USYez0RtwgmQ,951
7
+ lifx/devices/__init__.py,sha256=QQxJ6FewWEbihnjayjXwWPESsrwOWmylpwXKillQkuY,1017
8
8
  lifx/devices/base.py,sha256=x2RlGeCv60QLKklP6kA9wbCc3rmcHHhmvkSJHqTow5s,63151
9
+ lifx/devices/ceiling.py,sha256=hpzSDxUzPvvgr3RSiqljek6u5IYTocbLcqTt82pr_18,27542
9
10
  lifx/devices/hev.py,sha256=T5hvt2q_vdgPBvThx_-M7n5pZu9pL0y9Fs3Zz_KL0NM,15588
10
11
  lifx/devices/infrared.py,sha256=ePk9qxX_s-hv5gQMvio1Vv8FYiCd68HF0ySbWgSrvuU,8130
11
12
  lifx/devices/light.py,sha256=gk92lhViUWINGaxDWbs4qn8Stnn2fGCfRkC5Kk0Q-hI,34087
12
- lifx/devices/matrix.py,sha256=pjG8BkOf8vPKRvGggJip9m_exXF9nEFVP3j6ZLDoZbk,38667
13
+ lifx/devices/matrix.py,sha256=yZta863L7FZVu_NbRwRmu7mO0cx4EJ05p-iQvy_AeWE,42031
13
14
  lifx/devices/multizone.py,sha256=8OJ6zP5xgSCmlMQDj2mLUZ352EMkbYMbDZ1X-Cux7AU,32786
14
15
  lifx/effects/__init__.py,sha256=4DF31yp7RJic5JoltMlz5dCtF5KQobU6NOUtLUKkVKE,1509
15
16
  lifx/effects/base.py,sha256=YO0Hbg2VYHKPtfYnWxmrtzYoPGOi9BUXhn8HVFKv5IM,10283
@@ -21,11 +22,12 @@ lifx/effects/pulse.py,sha256=t5eyjfFWG1xT-RXKghRqHYJ9CG_50tPu4jsDapJZ2mw,8721
21
22
  lifx/effects/state_manager.py,sha256=iDfYowiCN5IJqcR1s-pM0mQEJpe-RDsMcOOSMmtPVDE,8983
22
23
  lifx/network/__init__.py,sha256=uSyA8r8qISG7qXUHbX8uk9A2E8rvDADgCcf94QIZ9so,499
23
24
  lifx/network/connection.py,sha256=aerPiYWf096lq8oBiS7JfE4k-P18GS50mNEC4TYa2g8,38401
24
- lifx/network/discovery.py,sha256=FoFoZcw3dtJs1daESiZiNXytanKQsMTdF9PjOxEgHM0,23804
25
+ lifx/network/discovery.py,sha256=syFfkDYWo0AEoBdEBjWqBm4K7UJwZW5x2K0FBMiA2I0,24186
25
26
  lifx/network/message.py,sha256=jCLC9v0tbBi54g5CaHLFM_nP1Izu8kJmo2tt23HHBbA,2600
26
27
  lifx/network/transport.py,sha256=8QS0YV32rdP0EDiPEwuvZXbplRWL08pmjKybd87mkZ0,11070
27
- lifx/products/__init__.py,sha256=P5ZtYsr48hCrPNv9PS8wZVdoitnQWVDp7jn_qWnvHmQ,569
28
+ lifx/products/__init__.py,sha256=pf2O-fzt6nOrQd-wmzhiog91tMiGa-dDbaSNtU2ZQfE,764
28
29
  lifx/products/generator.py,sha256=5bDFfrJ8ocwuhEr4dZB4LpVcqOqC3KxJSDiphPMu8CI,15660
30
+ lifx/products/quirks.py,sha256=B8Kb4pxaXmovMbjgXRfPPWre5JEvJrn8d6PAWK_FT1U,2544
29
31
  lifx/products/registry.py,sha256=ILIJlQxcxJUzRH-LGU_bnHjV-TxDEucKovuJcWvG4q8,43831
30
32
  lifx/protocol/__init__.py,sha256=-wjC-wBcb7fxi5I-mJr2Ad8K2YRflJFdLLdobfD-W1Q,56
31
33
  lifx/protocol/base.py,sha256=x4cKT5sbaEmILbmPH3y5Lwk6gj3h9Xv_JvTX91cPQwM,12354
@@ -37,10 +39,10 @@ lifx/protocol/protocol_types.py,sha256=m15A82zVrwAXomTqo-GfNmAIynVRDSV94UqHDkWgi
37
39
  lifx/protocol/serializer.py,sha256=Cl87-Y8_LnvqFANjorJK2CMoRtBGksB_Eq07xHMTqH0,10387
38
40
  lifx/theme/__init__.py,sha256=dg4Y25dYq22EemFyxQ1fyb3D_bP2hhxGCd9BE1g_hvk,1320
39
41
  lifx/theme/canvas.py,sha256=4h7lgN8iu_OdchObGDgbxTqQLCb-FRKC-M-YCWef_i4,8048
40
- lifx/theme/generators.py,sha256=L0X6_iApLx6XDboGlYunaVsl6nvUCqMfn23VQmRkyCk,6125
42
+ lifx/theme/generators.py,sha256=nq3Yvntq_h-eFHbmmow3LcAdA_hEbRRaP5mv9Bydrjk,6435
41
43
  lifx/theme/library.py,sha256=tKlKZNqJp8lRGDnilWyDm_Qr1vCRGGwuvWVS82anNpQ,21326
42
44
  lifx/theme/theme.py,sha256=qMEx_8E41C0Cc6f083XHiAXEglTv4YlXW0UFsG1rQKg,5521
43
- lifx_async-4.4.0.dist-info/METADATA,sha256=Ptnm4qrRJQQTnftUZWq_Fpj0PGT-rTlEFtYk3RlyERs,2609
44
- lifx_async-4.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
45
- lifx_async-4.4.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
46
- lifx_async-4.4.0.dist-info/RECORD,,
45
+ lifx_async-4.5.0.dist-info/METADATA,sha256=C9hkT241iql3e5gXwa1vXL9KNrPDZJ2DZmHIZiNKoHA,2609
46
+ lifx_async-4.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
+ lifx_async-4.5.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
48
+ lifx_async-4.5.0.dist-info/RECORD,,