lifx-async 4.4.1__py3-none-any.whl → 4.5.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/__init__.py CHANGED
@@ -16,6 +16,7 @@ from lifx.api import (
16
16
  )
17
17
  from lifx.color import HSBK, Colors
18
18
  from lifx.devices import (
19
+ CeilingLight,
19
20
  Device,
20
21
  DeviceInfo,
21
22
  DeviceVersion,
@@ -71,6 +72,7 @@ __all__ = [
71
72
  "MultiZoneLightState",
72
73
  "MatrixLight",
73
74
  "MatrixLightState",
75
+ "CeilingLight",
74
76
  # Color
75
77
  "HSBK",
76
78
  "Colors",
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
@@ -354,10 +354,7 @@ class MatrixLight(Light):
354
354
  def __init__(self, *args, **kwargs) -> None:
355
355
  """Initialize MatrixLight device.
356
356
 
357
- Args:
358
- serial: Device serial number
359
- ip: Device IP address
360
- port: Device port (default: 56700)
357
+ See :class:`Light` for parameter documentation.
361
358
  """
362
359
  super().__init__(*args, **kwargs)
363
360
  # Matrix specific properties
@@ -550,12 +547,15 @@ class MatrixLight(Light):
550
547
  as a list of color lists (one per tile). This is the matrix equivalent
551
548
  of MultiZoneLight's get_all_color_zones().
552
549
 
550
+ For tiles with >64 zones (e.g., 16x8 Ceiling with 128 zones), makes
551
+ multiple Get64 requests to fetch all colors.
552
+
553
553
  Always fetches from device. Tiles are queried sequentially to avoid
554
554
  overwhelming the device with concurrent requests.
555
555
 
556
556
  Returns:
557
557
  List of color lists, one per tile. Each inner list contains
558
- the colors for that tile (typically 64 for 8x8 tiles).
558
+ all colors for that tile (64 for 8x8 tiles, 128 for 16x8 Ceiling).
559
559
 
560
560
  Raises:
561
561
  LifxDeviceNotFoundError: If device is not connected
@@ -583,8 +583,28 @@ class MatrixLight(Light):
583
583
  # Fetch colors from each tile sequentially
584
584
  all_colors: list[list[HSBK]] = []
585
585
  for tile in device_chain:
586
- tile_colors = await self.get64(tile_index=tile.tile_index)
587
- all_colors.append(tile_colors)
586
+ tile_zone_count = tile.width * tile.height
587
+
588
+ if tile_zone_count <= 64:
589
+ # Single request for tiles with ≤64 zones
590
+ tile_colors = await self.get64(tile_index=tile.tile_index)
591
+ all_colors.append(tile_colors)
592
+ else:
593
+ # Multiple requests for tiles with >64 zones (e.g., 16x8 Ceiling)
594
+ # Split into multiple 64-zone requests by row
595
+ tile_colors = []
596
+ rows_per_request = 64 // tile.width # e.g., 64/16 = 4 rows
597
+
598
+ for y_offset in range(0, tile.height, rows_per_request):
599
+ chunk = await self.get64(
600
+ tile_index=tile.tile_index,
601
+ x=0,
602
+ y=y_offset,
603
+ width=tile.width,
604
+ )
605
+ tile_colors.extend(chunk)
606
+
607
+ all_colors.append(tile_colors)
588
608
 
589
609
  # Update state if it exists (flatten for state storage)
590
610
  if self._state is not None and hasattr(self._state, "tile_colors"):
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 4.4.1
3
+ Version: 4.5.1
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>
@@ -1,15 +1,16 @@
1
- lifx/__init__.py,sha256=GBubbz5IrtHDMCJ9aeBzOGztvg0GTLE70rS_di1VMBQ,2524
1
+ lifx/__init__.py,sha256=Kn1QXDD2xcT5rgqGLfXFtnVF8ATtlg0FVxOvpaG4Z1M,2562
2
2
  lifx/api.py,sha256=PFS2b28ow40kCQvT_MKvBLZD6fKCbvsoOQFtiODDrPE,33861
3
3
  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=8Z0FbBGUzRhvqs3h9fL9ZQ691C9mAoA6kuOrhe89Qwk,41061
13
+ lifx/devices/matrix.py,sha256=AVNM2071NFtPg3Eq7kZ3asRGus36He02iDDvIa8RaQ0,41951
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
@@ -40,7 +42,7 @@ lifx/theme/canvas.py,sha256=4h7lgN8iu_OdchObGDgbxTqQLCb-FRKC-M-YCWef_i4,8048
40
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.1.dist-info/METADATA,sha256=GPXITqP1r-6RIp91S1SuabkN4ie0cJxMvM498v3AKwg,2609
44
- lifx_async-4.4.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
45
- lifx_async-4.4.1.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
46
- lifx_async-4.4.1.dist-info/RECORD,,
45
+ lifx_async-4.5.1.dist-info/METADATA,sha256=OjmlAONCtPMp3Ur3aNJB8HHmkSDq8wKmu9I00zsXvnk,2609
46
+ lifx_async-4.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
+ lifx_async-4.5.1.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
48
+ lifx_async-4.5.1.dist-info/RECORD,,