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 +2 -0
- lifx/devices/ceiling.py +784 -0
- lifx/devices/matrix.py +96 -21
- lifx/network/discovery.py +10 -2
- lifx/products/__init__.py +8 -0
- lifx/products/quirks.py +91 -0
- lifx/theme/generators.py +8 -2
- {lifx_async-4.4.0.dist-info → lifx_async-4.5.0.dist-info}/METADATA +1 -1
- {lifx_async-4.4.0.dist-info → lifx_async-4.5.0.dist-info}/RECORD +11 -9
- {lifx_async-4.4.0.dist-info → lifx_async-4.5.0.dist-info}/WHEEL +0 -0
- {lifx_async-4.4.0.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
|
@@ -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
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
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=
|
|
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
|
|
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
|
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
|
-
|
|
171
|
-
|
|
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()
|
|
@@ -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
|
|
@@ -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=
|
|
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
|