lifx-async 4.4.1__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 +2 -0
- lifx/devices/ceiling.py +784 -0
- lifx/devices/matrix.py +26 -3
- lifx/network/discovery.py +10 -2
- lifx/products/__init__.py +8 -0
- lifx/products/quirks.py +91 -0
- {lifx_async-4.4.1.dist-info → lifx_async-4.5.0.dist-info}/METADATA +1 -1
- {lifx_async-4.4.1.dist-info → lifx_async-4.5.0.dist-info}/RECORD +10 -8
- {lifx_async-4.4.1.dist-info → lifx_async-4.5.0.dist-info}/WHEEL +0 -0
- {lifx_async-4.4.1.dist-info → lifx_async-4.5.0.dist-info}/licenses/LICENSE +0 -0
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",
|
lifx/devices/ceiling.py
ADDED
|
@@ -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
|
@@ -550,12 +550,15 @@ class MatrixLight(Light):
|
|
|
550
550
|
as a list of color lists (one per tile). This is the matrix equivalent
|
|
551
551
|
of MultiZoneLight's get_all_color_zones().
|
|
552
552
|
|
|
553
|
+
For tiles with >64 zones (e.g., 16x8 Ceiling with 128 zones), makes
|
|
554
|
+
multiple Get64 requests to fetch all colors.
|
|
555
|
+
|
|
553
556
|
Always fetches from device. Tiles are queried sequentially to avoid
|
|
554
557
|
overwhelming the device with concurrent requests.
|
|
555
558
|
|
|
556
559
|
Returns:
|
|
557
560
|
List of color lists, one per tile. Each inner list contains
|
|
558
|
-
|
|
561
|
+
all colors for that tile (64 for 8x8 tiles, 128 for 16x8 Ceiling).
|
|
559
562
|
|
|
560
563
|
Raises:
|
|
561
564
|
LifxDeviceNotFoundError: If device is not connected
|
|
@@ -583,8 +586,28 @@ class MatrixLight(Light):
|
|
|
583
586
|
# Fetch colors from each tile sequentially
|
|
584
587
|
all_colors: list[list[HSBK]] = []
|
|
585
588
|
for tile in device_chain:
|
|
586
|
-
|
|
587
|
-
|
|
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)
|
|
588
611
|
|
|
589
612
|
# Update state if it exists (flatten for state storage)
|
|
590
613
|
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
|
|
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
|
]
|
lifx/products/quirks.py
ADDED
|
@@ -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
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
44
|
-
lifx_async-4.
|
|
45
|
-
lifx_async-4.
|
|
46
|
-
lifx_async-4.
|
|
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,,
|
|
File without changes
|
|
File without changes
|