lifx-async 4.3.8__py3-none-any.whl → 4.4.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/__init__.py +10 -0
- lifx/api.py +19 -23
- lifx/const.py +2 -1
- lifx/devices/__init__.py +12 -9
- lifx/devices/base.py +590 -58
- lifx/devices/hev.py +168 -8
- lifx/devices/infrared.py +117 -4
- lifx/devices/light.py +175 -10
- lifx/devices/matrix.py +172 -14
- lifx/devices/multizone.py +156 -21
- lifx/network/connection.py +5 -6
- lifx/protocol/generator.py +41 -0
- lifx/protocol/protocol_types.py +9 -4
- {lifx_async-4.3.8.dist-info → lifx_async-4.4.0.dist-info}/METADATA +1 -1
- {lifx_async-4.3.8.dist-info → lifx_async-4.4.0.dist-info}/RECORD +17 -17
- {lifx_async-4.3.8.dist-info → lifx_async-4.4.0.dist-info}/WHEEL +1 -1
- {lifx_async-4.3.8.dist-info → lifx_async-4.4.0.dist-info}/licenses/LICENSE +0 -0
lifx/devices/base.py
CHANGED
|
@@ -9,7 +9,7 @@ import time
|
|
|
9
9
|
import uuid
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from math import floor, log10
|
|
12
|
-
from typing import Self
|
|
12
|
+
from typing import TYPE_CHECKING, Generic, Self, TypeVar, cast
|
|
13
13
|
|
|
14
14
|
from lifx.const import (
|
|
15
15
|
DEFAULT_MAX_RETRIES,
|
|
@@ -25,6 +25,9 @@ from lifx.products.registry import ProductInfo, get_product
|
|
|
25
25
|
from lifx.protocol import packets
|
|
26
26
|
from lifx.protocol.models import Serial
|
|
27
27
|
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from lifx.devices import HevLight, InfraredLight, Light, MatrixLight, MultiZoneLight
|
|
30
|
+
|
|
28
31
|
_LOGGER = logging.getLogger(__name__)
|
|
29
32
|
|
|
30
33
|
|
|
@@ -87,38 +90,175 @@ class FirmwareInfo:
|
|
|
87
90
|
version_major: int
|
|
88
91
|
version_minor: int
|
|
89
92
|
|
|
93
|
+
@property
|
|
94
|
+
def as_dict(self) -> dict[str, int]:
|
|
95
|
+
"""Return firmware info as dict."""
|
|
96
|
+
return {
|
|
97
|
+
"version_major": self.version_major,
|
|
98
|
+
"version_minor": self.version_minor,
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
|
|
91
102
|
@dataclass
|
|
92
|
-
class
|
|
93
|
-
"""Device location information.
|
|
103
|
+
class CollectionInfo:
|
|
104
|
+
"""Device location and group collection information.
|
|
94
105
|
|
|
95
106
|
Attributes:
|
|
96
|
-
|
|
97
|
-
label:
|
|
98
|
-
updated_at: Timestamp when
|
|
107
|
+
uuid: Collection UUID (16 hexadecimal characters)
|
|
108
|
+
label: Collection label (up to 32 characters)
|
|
109
|
+
updated_at: Timestamp when group was last updated (nanoseconds)
|
|
99
110
|
"""
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
uuid: str
|
|
102
113
|
label: str
|
|
103
114
|
updated_at: int
|
|
104
115
|
|
|
116
|
+
@property
|
|
117
|
+
def as_dict(self) -> dict[str, str | int]:
|
|
118
|
+
"""Return group info as dict."""
|
|
119
|
+
return {"uuid": self.uuid, "label": self.label, "updated_at": self.updated_at}
|
|
120
|
+
|
|
105
121
|
|
|
106
122
|
@dataclass
|
|
107
|
-
class
|
|
108
|
-
"""Device
|
|
123
|
+
class DeviceCapabilities:
|
|
124
|
+
"""Device capabilities from product registry.
|
|
109
125
|
|
|
110
126
|
Attributes:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
has_color: Supports color control
|
|
128
|
+
has_multizone: Supports multizone control (strips, beams)
|
|
129
|
+
has_chain: Supports chaining (tiles)
|
|
130
|
+
has_matrix: Supports 2D matrix control (tiles, candle, path)
|
|
131
|
+
has_infrared: Supports infrared LED
|
|
132
|
+
has_hev: Supports HEV (High Energy Visible) cleaning cycles
|
|
133
|
+
has_extended_multizone: Supports extended multizone protocol
|
|
134
|
+
kelvin_min: Minimum color temperature (Kelvin)
|
|
135
|
+
kelvin_max: Maximum color temperature (Kelvin)
|
|
114
136
|
"""
|
|
115
137
|
|
|
116
|
-
|
|
138
|
+
has_color: bool
|
|
139
|
+
has_multizone: bool
|
|
140
|
+
has_chain: bool
|
|
141
|
+
has_matrix: bool
|
|
142
|
+
has_infrared: bool
|
|
143
|
+
has_hev: bool
|
|
144
|
+
has_extended_multizone: bool
|
|
145
|
+
kelvin_min: int | None
|
|
146
|
+
kelvin_max: int | None
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def has_variable_color_temp(self) -> bool:
|
|
150
|
+
"""Check if device supports variable color temperature."""
|
|
151
|
+
return (
|
|
152
|
+
self.kelvin_min is not None
|
|
153
|
+
and self.kelvin_max is not None
|
|
154
|
+
and self.kelvin_min != self.kelvin_max
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def as_dict(self) -> dict[str, bool | int]:
|
|
159
|
+
"""Return DeviceCapabilities as a dict."""
|
|
160
|
+
return {
|
|
161
|
+
"has_color": self.has_color,
|
|
162
|
+
"has_multizone": self.has_multizone,
|
|
163
|
+
"has_extended_multizone": self.has_extended_multizone,
|
|
164
|
+
"has_chain": self.has_chain,
|
|
165
|
+
"has_matrix": self.has_matrix,
|
|
166
|
+
"has_infrared": self.has_infrared,
|
|
167
|
+
"has_hev": self.has_hev,
|
|
168
|
+
"has_variable_color_temp": self.has_variable_color_temp,
|
|
169
|
+
"kelvin_max": self.kelvin_max if self.kelvin_max is not None else 9000,
|
|
170
|
+
"kelvin_min": self.kelvin_min if self.kelvin_min is not None else 1500,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class DeviceState:
|
|
176
|
+
"""Base device state.
|
|
177
|
+
|
|
178
|
+
Attributes:
|
|
179
|
+
model: Friendly product name (e.g., "LIFX A19")
|
|
180
|
+
label: Device label (user-assigned name)
|
|
181
|
+
serial: Device serial number (6 bytes)
|
|
182
|
+
mac_address: Device MAC address (formatted string)
|
|
183
|
+
capabilities: Device capabilities from product registry
|
|
184
|
+
power: Power level (0 = off, 65535 = on)
|
|
185
|
+
host_firmware: Host firmware version
|
|
186
|
+
wifi_firmware: WiFi firmware version
|
|
187
|
+
location: Location tuple (UUID bytes, label, updated_at)
|
|
188
|
+
group: Group tuple (UUID bytes, label, updated_at)
|
|
189
|
+
last_updated: Timestamp of last state refresh
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
model: str
|
|
117
193
|
label: str
|
|
118
|
-
|
|
194
|
+
serial: str
|
|
195
|
+
mac_address: str
|
|
196
|
+
capabilities: DeviceCapabilities
|
|
197
|
+
power: int
|
|
198
|
+
host_firmware: FirmwareInfo
|
|
199
|
+
wifi_firmware: FirmwareInfo
|
|
200
|
+
location: CollectionInfo
|
|
201
|
+
group: CollectionInfo
|
|
202
|
+
last_updated: float
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def as_dict(
|
|
206
|
+
self,
|
|
207
|
+
) -> dict[str, str | int | float | dict[str, bool | int] | dict[str, str | int]]:
|
|
208
|
+
"""Return DeviceState as a dictionary."""
|
|
209
|
+
return {
|
|
210
|
+
"model": self.model,
|
|
211
|
+
"label": self.label,
|
|
212
|
+
"serial": self.serial,
|
|
213
|
+
"mac_address": self.mac_address,
|
|
214
|
+
"capabilities": self.capabilities.as_dict,
|
|
215
|
+
"power": self.power,
|
|
216
|
+
"host_firmware": self.host_firmware.as_dict,
|
|
217
|
+
"wifi_firmware": self.wifi_firmware.as_dict,
|
|
218
|
+
"location": self.location.as_dict,
|
|
219
|
+
"group": self.group.as_dict,
|
|
220
|
+
"last_updated": self.last_updated,
|
|
221
|
+
}
|
|
119
222
|
|
|
223
|
+
@property
|
|
224
|
+
def is_on(self) -> bool:
|
|
225
|
+
"""Check if device is powered on."""
|
|
226
|
+
return self.power > 0
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def location_name(self) -> str:
|
|
230
|
+
"""Get location label."""
|
|
231
|
+
return self.location.label
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def group_name(self) -> str:
|
|
235
|
+
"""Get group label."""
|
|
236
|
+
return self.group.label
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def age(self) -> float:
|
|
240
|
+
"""Get age of state in seconds."""
|
|
241
|
+
import time
|
|
242
|
+
|
|
243
|
+
return time.time() - self.last_updated
|
|
244
|
+
|
|
245
|
+
def is_fresh(self, max_age: float = 5.0) -> bool:
|
|
246
|
+
"""Check if state is fresh (recently updated).
|
|
120
247
|
|
|
121
|
-
|
|
248
|
+
Args:
|
|
249
|
+
max_age: Maximum age in seconds (default: 5.0)
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
True if state age is less than max_age
|
|
253
|
+
"""
|
|
254
|
+
return self.age < max_age
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# TypeVar for generic state type, bound to DeviceState
|
|
258
|
+
StateT = TypeVar("StateT", bound=DeviceState)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class Device(Generic[StateT]):
|
|
122
262
|
"""Base class for LIFX devices.
|
|
123
263
|
|
|
124
264
|
This class provides common functionality for all LIFX devices:
|
|
@@ -284,13 +424,19 @@ class Device:
|
|
|
284
424
|
self._version: DeviceVersion | None = None
|
|
285
425
|
self._host_firmware: FirmwareInfo | None = None
|
|
286
426
|
self._wifi_firmware: FirmwareInfo | None = None
|
|
287
|
-
self._location:
|
|
288
|
-
self._group:
|
|
427
|
+
self._location: CollectionInfo | None = None
|
|
428
|
+
self._group: CollectionInfo | None = None
|
|
289
429
|
self._mac_address: str | None = None
|
|
290
430
|
|
|
291
431
|
# Product capabilities for device features (populated on first use)
|
|
292
432
|
self._capabilities: ProductInfo | None = None
|
|
293
433
|
|
|
434
|
+
# State management (populated by connect() factory or _initialize_state())
|
|
435
|
+
self._state: StateT | None = None
|
|
436
|
+
self._refresh_task: asyncio.Task[None] | None = None
|
|
437
|
+
self._refresh_lock = asyncio.Lock()
|
|
438
|
+
self._is_closed = False
|
|
439
|
+
|
|
294
440
|
@classmethod
|
|
295
441
|
async def from_ip(
|
|
296
442
|
cls,
|
|
@@ -355,10 +501,157 @@ class Device:
|
|
|
355
501
|
|
|
356
502
|
raise LifxDeviceNotFoundError()
|
|
357
503
|
|
|
504
|
+
@classmethod
|
|
505
|
+
async def connect(
|
|
506
|
+
cls,
|
|
507
|
+
ip: str,
|
|
508
|
+
serial: str | None = None,
|
|
509
|
+
port: int = LIFX_UDP_PORT,
|
|
510
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
511
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
512
|
+
) -> Light | HevLight | InfraredLight | MultiZoneLight | MatrixLight:
|
|
513
|
+
"""Create and return a fully initialized device instance.
|
|
514
|
+
|
|
515
|
+
This factory method creates the appropriate device type (Light, etc)
|
|
516
|
+
based on the device's capabilities and initializes its state. The returned
|
|
517
|
+
device MUST be used with an async context manager.
|
|
518
|
+
|
|
519
|
+
The returned device subclass has guaranteed initialized state - the state
|
|
520
|
+
property will never be None for devices created via this method.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
ip: IP address of the device
|
|
524
|
+
serial: Optional serial number (12-digit hex, with or without colons).
|
|
525
|
+
If None, queries device to get serial.
|
|
526
|
+
port: Port number (default LIFX_UDP_PORT)
|
|
527
|
+
timeout: Request timeout for this device instance
|
|
528
|
+
max_retries: Maximum number of retry attempts
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Fully initialized device instance (Light, MultiZoneLight, MatrixLight, etc.)
|
|
532
|
+
with complete state loaded and guaranteed non-None state property.
|
|
533
|
+
|
|
534
|
+
Raises:
|
|
535
|
+
LifxDeviceNotFoundError: If device cannot be found or contacted
|
|
536
|
+
LifxTimeoutError: If device does not respond
|
|
537
|
+
ValueError: If serial format is invalid
|
|
538
|
+
|
|
539
|
+
Example:
|
|
540
|
+
```python
|
|
541
|
+
# Connect by IP (serial auto-detected)
|
|
542
|
+
device = await Device.connect(ip="192.168.1.100")
|
|
543
|
+
async with device:
|
|
544
|
+
# device.state is guaranteed to be initialized
|
|
545
|
+
print(f"{device.state.model}: {device.state.label}")
|
|
546
|
+
if device.state.is_on:
|
|
547
|
+
print("Device is on")
|
|
548
|
+
|
|
549
|
+
# Connect with known serial
|
|
550
|
+
device = await Device.connect(ip="192.168.1.100", serial="d073d5123456")
|
|
551
|
+
async with device:
|
|
552
|
+
await device.set_power(True)
|
|
553
|
+
```
|
|
554
|
+
"""
|
|
555
|
+
# Step 1: Get serial if not provided
|
|
556
|
+
if serial is None:
|
|
557
|
+
temp_conn = DeviceConnection(
|
|
558
|
+
serial="000000000000",
|
|
559
|
+
ip=ip,
|
|
560
|
+
port=port,
|
|
561
|
+
timeout=timeout,
|
|
562
|
+
max_retries=max_retries,
|
|
563
|
+
)
|
|
564
|
+
try:
|
|
565
|
+
response = await temp_conn.request(
|
|
566
|
+
packets.Device.GetService(), timeout=timeout
|
|
567
|
+
)
|
|
568
|
+
if response and isinstance(response, packets.Device.StateService):
|
|
569
|
+
if temp_conn.serial and temp_conn.serial != "000000000000":
|
|
570
|
+
serial = temp_conn.serial
|
|
571
|
+
else:
|
|
572
|
+
raise LifxDeviceNotFoundError(
|
|
573
|
+
"Could not determine device serial"
|
|
574
|
+
)
|
|
575
|
+
else:
|
|
576
|
+
raise LifxDeviceNotFoundError("No response from device")
|
|
577
|
+
finally:
|
|
578
|
+
await temp_conn.close()
|
|
579
|
+
|
|
580
|
+
# Step 2: Normalize serial (accept with or without colons)
|
|
581
|
+
serial = serial.replace(":", "")
|
|
582
|
+
|
|
583
|
+
# Step 3: Create temporary device to get product info
|
|
584
|
+
temp_device = cls(
|
|
585
|
+
serial=serial,
|
|
586
|
+
ip=ip,
|
|
587
|
+
port=port,
|
|
588
|
+
timeout=timeout,
|
|
589
|
+
max_retries=max_retries,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
try:
|
|
593
|
+
# Get version to determine product
|
|
594
|
+
version = await temp_device.get_version()
|
|
595
|
+
product_info = get_product(version.product)
|
|
596
|
+
|
|
597
|
+
if product_info is None:
|
|
598
|
+
raise LifxDeviceNotFoundError(f"Unknown product ID: {version.product}")
|
|
599
|
+
|
|
600
|
+
# Step 4: Determine correct device class based on capabilities
|
|
601
|
+
# Import device classes here to avoid circular imports
|
|
602
|
+
from typing import TYPE_CHECKING
|
|
603
|
+
|
|
604
|
+
if TYPE_CHECKING:
|
|
605
|
+
from lifx.devices.hev import HevLight
|
|
606
|
+
from lifx.devices.infrared import InfraredLight
|
|
607
|
+
from lifx.devices.light import Light
|
|
608
|
+
from lifx.devices.matrix import MatrixLight
|
|
609
|
+
from lifx.devices.multizone import MultiZoneLight
|
|
610
|
+
|
|
611
|
+
device_class: type[Device] = cls
|
|
612
|
+
|
|
613
|
+
if product_info.has_matrix:
|
|
614
|
+
from lifx.devices.matrix import MatrixLight
|
|
615
|
+
|
|
616
|
+
device_class = MatrixLight
|
|
617
|
+
elif product_info.has_multizone:
|
|
618
|
+
from lifx.devices.multizone import MultiZoneLight
|
|
619
|
+
|
|
620
|
+
device_class = MultiZoneLight
|
|
621
|
+
elif product_info.has_infrared:
|
|
622
|
+
from lifx.devices.infrared import InfraredLight
|
|
623
|
+
|
|
624
|
+
device_class = InfraredLight
|
|
625
|
+
elif product_info.has_hev:
|
|
626
|
+
from lifx.devices.hev import HevLight
|
|
627
|
+
|
|
628
|
+
device_class = HevLight
|
|
629
|
+
elif product_info.has_color:
|
|
630
|
+
from lifx.devices.light import Light
|
|
631
|
+
|
|
632
|
+
device_class = Light
|
|
633
|
+
|
|
634
|
+
# Step 5: Create instance of correct device class
|
|
635
|
+
device = device_class(
|
|
636
|
+
serial=serial,
|
|
637
|
+
ip=ip,
|
|
638
|
+
port=port,
|
|
639
|
+
timeout=timeout,
|
|
640
|
+
max_retries=max_retries,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# Type system note: device._state is guaranteed non-None after
|
|
644
|
+
# _initialize_state().
|
|
645
|
+
# Each subclass overrides _state to be non-optional
|
|
646
|
+
return device # type: ignore[return-value]
|
|
647
|
+
|
|
648
|
+
finally:
|
|
649
|
+
# Clean up temporary device
|
|
650
|
+
await temp_device.connection.close()
|
|
651
|
+
|
|
358
652
|
async def __aenter__(self) -> Self:
|
|
359
653
|
"""Enter async context manager."""
|
|
360
|
-
|
|
361
|
-
await self._setup()
|
|
654
|
+
await self._initialize_state()
|
|
362
655
|
return self
|
|
363
656
|
|
|
364
657
|
async def __aexit__(
|
|
@@ -368,8 +661,8 @@ class Device:
|
|
|
368
661
|
exc_tb: object,
|
|
369
662
|
) -> None:
|
|
370
663
|
"""Exit async context manager and close connection."""
|
|
371
|
-
# Close
|
|
372
|
-
await self.
|
|
664
|
+
# Close device (cancels refresh tasks and connection)
|
|
665
|
+
await self.close()
|
|
373
666
|
|
|
374
667
|
async def _setup(self) -> None:
|
|
375
668
|
"""Populate device capabilities, state and metadata."""
|
|
@@ -488,16 +781,22 @@ class Device:
|
|
|
488
781
|
self._raise_if_unhandled(state)
|
|
489
782
|
|
|
490
783
|
# Store label
|
|
491
|
-
|
|
784
|
+
label_value = state.label
|
|
785
|
+
self._label = label_value
|
|
786
|
+
# Update state if it exists
|
|
787
|
+
if self._state is not None:
|
|
788
|
+
self._state.label = label_value
|
|
789
|
+
self._state.last_updated = __import__("time").time()
|
|
790
|
+
|
|
492
791
|
_LOGGER.debug(
|
|
493
792
|
{
|
|
494
793
|
"class": "Device",
|
|
495
794
|
"method": "get_label",
|
|
496
795
|
"action": "query",
|
|
497
|
-
"reply": {"label":
|
|
796
|
+
"reply": {"label": label_value},
|
|
498
797
|
}
|
|
499
798
|
)
|
|
500
|
-
return
|
|
799
|
+
return label_value
|
|
501
800
|
|
|
502
801
|
async def set_label(self, label: str) -> None:
|
|
503
802
|
"""Set device label/name.
|
|
@@ -531,8 +830,13 @@ class Device:
|
|
|
531
830
|
)
|
|
532
831
|
self._raise_if_unhandled(result)
|
|
533
832
|
|
|
534
|
-
|
|
535
|
-
|
|
833
|
+
if result:
|
|
834
|
+
self._label = label
|
|
835
|
+
|
|
836
|
+
if self._state is not None:
|
|
837
|
+
self._state.label = label
|
|
838
|
+
await self._schedule_refresh()
|
|
839
|
+
|
|
536
840
|
_LOGGER.debug(
|
|
537
841
|
{
|
|
538
842
|
"class": "Device",
|
|
@@ -567,15 +871,21 @@ class Device:
|
|
|
567
871
|
self._raise_if_unhandled(state)
|
|
568
872
|
|
|
569
873
|
# Power level is uint16 (0 or 65535)
|
|
874
|
+
power_level = state.level
|
|
875
|
+
# Update state if it exists
|
|
876
|
+
if self._state is not None:
|
|
877
|
+
self._state.power = power_level
|
|
878
|
+
self._state.last_updated = __import__("time").time()
|
|
879
|
+
|
|
570
880
|
_LOGGER.debug(
|
|
571
881
|
{
|
|
572
882
|
"class": "Device",
|
|
573
883
|
"method": "get_power",
|
|
574
884
|
"action": "query",
|
|
575
|
-
"reply": {"level":
|
|
885
|
+
"reply": {"level": power_level},
|
|
576
886
|
}
|
|
577
887
|
)
|
|
578
|
-
return
|
|
888
|
+
return power_level
|
|
579
889
|
|
|
580
890
|
async def set_power(self, level: bool | int) -> None:
|
|
581
891
|
"""Set device power state.
|
|
@@ -609,8 +919,6 @@ class Device:
|
|
|
609
919
|
if level not in (0, 65535):
|
|
610
920
|
raise ValueError(f"Power level must be 0 or 65535, got {level}")
|
|
611
921
|
power_level = level
|
|
612
|
-
else:
|
|
613
|
-
raise TypeError(f"Expected bool or int, got {type(level).__name__}")
|
|
614
922
|
|
|
615
923
|
# Request automatically handles acknowledgement
|
|
616
924
|
result = await self.connection.request(
|
|
@@ -627,6 +935,9 @@ class Device:
|
|
|
627
935
|
}
|
|
628
936
|
)
|
|
629
937
|
|
|
938
|
+
if result and self._state is not None:
|
|
939
|
+
await self._schedule_refresh()
|
|
940
|
+
|
|
630
941
|
async def get_version(self) -> DeviceVersion:
|
|
631
942
|
"""Get device version information.
|
|
632
943
|
|
|
@@ -843,13 +1154,13 @@ class Device:
|
|
|
843
1154
|
)
|
|
844
1155
|
return firmware
|
|
845
1156
|
|
|
846
|
-
async def get_location(self) ->
|
|
1157
|
+
async def get_location(self) -> CollectionInfo:
|
|
847
1158
|
"""Get device location information.
|
|
848
1159
|
|
|
849
1160
|
Always fetches from device.
|
|
850
1161
|
|
|
851
1162
|
Returns:
|
|
852
|
-
|
|
1163
|
+
CollectionInfo with location UUID, label, and updated timestamp
|
|
853
1164
|
|
|
854
1165
|
Raises:
|
|
855
1166
|
LifxDeviceNotFoundError: If device is not connected
|
|
@@ -861,20 +1172,22 @@ class Device:
|
|
|
861
1172
|
```python
|
|
862
1173
|
location = await device.get_location()
|
|
863
1174
|
print(f"Location: {location.label}")
|
|
864
|
-
print(f"Location ID: {location.
|
|
1175
|
+
print(f"Location ID: {location.uuid}")
|
|
865
1176
|
```
|
|
866
1177
|
"""
|
|
867
1178
|
# Request automatically unpacks response
|
|
868
1179
|
state = await self.connection.request(packets.Device.GetLocation()) # type: ignore
|
|
869
1180
|
self._raise_if_unhandled(state)
|
|
870
1181
|
|
|
871
|
-
location =
|
|
872
|
-
|
|
1182
|
+
location = CollectionInfo(
|
|
1183
|
+
uuid=state.location.hex(),
|
|
873
1184
|
label=state.label,
|
|
874
1185
|
updated_at=state.updated_at,
|
|
875
1186
|
)
|
|
876
1187
|
|
|
877
1188
|
self._location = location
|
|
1189
|
+
if self._state is not None:
|
|
1190
|
+
self._state.location = location
|
|
878
1191
|
|
|
879
1192
|
_LOGGER.debug(
|
|
880
1193
|
{
|
|
@@ -958,6 +1271,7 @@ class Device:
|
|
|
958
1271
|
and isinstance(state_packet.location, bytes)
|
|
959
1272
|
):
|
|
960
1273
|
location_uuid_to_use = state_packet.location
|
|
1274
|
+
assert location_uuid_to_use is not None
|
|
961
1275
|
# Type narrowing: we know location_uuid_to_use is not None here
|
|
962
1276
|
_LOGGER.debug(
|
|
963
1277
|
{
|
|
@@ -1010,11 +1324,17 @@ class Device:
|
|
|
1010
1324
|
)
|
|
1011
1325
|
self._raise_if_unhandled(result)
|
|
1012
1326
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1327
|
+
if result:
|
|
1328
|
+
self._location = CollectionInfo(
|
|
1329
|
+
uuid=location_uuid_to_use.hex(), label=label, updated_at=updated_at
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
if result and self._state is not None:
|
|
1333
|
+
self._state.location.uuid = location_uuid_to_use.hex()
|
|
1334
|
+
self._state.location.label = label
|
|
1335
|
+
self._state.location.updated_at = updated_at
|
|
1336
|
+
await self._schedule_refresh()
|
|
1337
|
+
|
|
1018
1338
|
_LOGGER.debug(
|
|
1019
1339
|
{
|
|
1020
1340
|
"class": "Device",
|
|
@@ -1028,13 +1348,13 @@ class Device:
|
|
|
1028
1348
|
}
|
|
1029
1349
|
)
|
|
1030
1350
|
|
|
1031
|
-
async def get_group(self) ->
|
|
1351
|
+
async def get_group(self) -> CollectionInfo:
|
|
1032
1352
|
"""Get device group information.
|
|
1033
1353
|
|
|
1034
1354
|
Always fetches from device.
|
|
1035
1355
|
|
|
1036
1356
|
Returns:
|
|
1037
|
-
|
|
1357
|
+
CollectionInfo with group UUID, label, and updated timestamp
|
|
1038
1358
|
|
|
1039
1359
|
Raises:
|
|
1040
1360
|
LifxDeviceNotFoundError: If device is not connected
|
|
@@ -1046,20 +1366,22 @@ class Device:
|
|
|
1046
1366
|
```python
|
|
1047
1367
|
group = await device.get_group()
|
|
1048
1368
|
print(f"Group: {group.label}")
|
|
1049
|
-
print(f"Group ID: {group.
|
|
1369
|
+
print(f"Group ID: {group.uuid}")
|
|
1050
1370
|
```
|
|
1051
1371
|
"""
|
|
1052
1372
|
# Request automatically unpacks response
|
|
1053
1373
|
state = await self.connection.request(packets.Device.GetGroup()) # type: ignore
|
|
1054
1374
|
self._raise_if_unhandled(state)
|
|
1055
1375
|
|
|
1056
|
-
group =
|
|
1057
|
-
|
|
1376
|
+
group = CollectionInfo(
|
|
1377
|
+
uuid=state.group.hex(),
|
|
1058
1378
|
label=state.label,
|
|
1059
1379
|
updated_at=state.updated_at,
|
|
1060
1380
|
)
|
|
1061
1381
|
|
|
1062
1382
|
self._group = group
|
|
1383
|
+
if self._state is not None:
|
|
1384
|
+
self._state.group = group
|
|
1063
1385
|
|
|
1064
1386
|
_LOGGER.debug(
|
|
1065
1387
|
{
|
|
@@ -1067,7 +1389,7 @@ class Device:
|
|
|
1067
1389
|
"method": "get_group",
|
|
1068
1390
|
"action": "query",
|
|
1069
1391
|
"reply": {
|
|
1070
|
-
"
|
|
1392
|
+
"uuid": state.group.hex(),
|
|
1071
1393
|
"label": state.label,
|
|
1072
1394
|
"updated_at": state.updated_at,
|
|
1073
1395
|
},
|
|
@@ -1143,6 +1465,7 @@ class Device:
|
|
|
1143
1465
|
and isinstance(state_packet.group, bytes)
|
|
1144
1466
|
):
|
|
1145
1467
|
group_uuid_to_use = state_packet.group
|
|
1468
|
+
assert group_uuid_to_use is not None
|
|
1146
1469
|
# Type narrowing: we know group_uuid_to_use is not None here
|
|
1147
1470
|
_LOGGER.debug(
|
|
1148
1471
|
{
|
|
@@ -1195,11 +1518,17 @@ class Device:
|
|
|
1195
1518
|
)
|
|
1196
1519
|
self._raise_if_unhandled(result)
|
|
1197
1520
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1521
|
+
if result:
|
|
1522
|
+
self._group = CollectionInfo(
|
|
1523
|
+
uuid=group_uuid_to_use.hex(), label=label, updated_at=updated_at
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
if result and self._state is not None:
|
|
1527
|
+
self._state.location.uuid = group_uuid_to_use.hex()
|
|
1528
|
+
self._state.location.label = label
|
|
1529
|
+
self._state.location.updated_at = updated_at
|
|
1530
|
+
await self._schedule_refresh()
|
|
1531
|
+
|
|
1203
1532
|
_LOGGER.debug(
|
|
1204
1533
|
{
|
|
1205
1534
|
"class": "Device",
|
|
@@ -1249,6 +1578,187 @@ class Device:
|
|
|
1249
1578
|
}
|
|
1250
1579
|
)
|
|
1251
1580
|
|
|
1581
|
+
async def close(self) -> None:
|
|
1582
|
+
"""Close device connection and cleanup resources.
|
|
1583
|
+
|
|
1584
|
+
Cancels any pending refresh tasks and closes the network connection.
|
|
1585
|
+
Called automatically when exiting the async context manager.
|
|
1586
|
+
"""
|
|
1587
|
+
self._is_closed = True
|
|
1588
|
+
if self._refresh_task and not self._refresh_task.done():
|
|
1589
|
+
self._refresh_task.cancel()
|
|
1590
|
+
try:
|
|
1591
|
+
await self._refresh_task
|
|
1592
|
+
except asyncio.CancelledError:
|
|
1593
|
+
pass
|
|
1594
|
+
await self.connection.close()
|
|
1595
|
+
|
|
1596
|
+
@property
|
|
1597
|
+
def state(self) -> StateT | None:
|
|
1598
|
+
"""Get device state if available.
|
|
1599
|
+
|
|
1600
|
+
State is populated by the connect() factory method or by calling
|
|
1601
|
+
_initialize_state() directly. Returns None if state has not been initialized.
|
|
1602
|
+
|
|
1603
|
+
Returns:
|
|
1604
|
+
State with current device state, or None if not initialized
|
|
1605
|
+
"""
|
|
1606
|
+
return self._state
|
|
1607
|
+
|
|
1608
|
+
def _create_capabilities(self) -> DeviceCapabilities:
|
|
1609
|
+
"""Create DeviceCapabilities instance from product registry.
|
|
1610
|
+
|
|
1611
|
+
Returns:
|
|
1612
|
+
DeviceCapabilities instance
|
|
1613
|
+
|
|
1614
|
+
Raises:
|
|
1615
|
+
RuntimeError: If capabilities have not been loaded
|
|
1616
|
+
"""
|
|
1617
|
+
assert self._capabilities is not None
|
|
1618
|
+
product_info = self._capabilities
|
|
1619
|
+
|
|
1620
|
+
return DeviceCapabilities(
|
|
1621
|
+
has_color=product_info.has_color,
|
|
1622
|
+
has_multizone=product_info.has_multizone,
|
|
1623
|
+
has_chain=product_info.has_chain,
|
|
1624
|
+
has_matrix=product_info.has_matrix,
|
|
1625
|
+
has_infrared=product_info.has_infrared,
|
|
1626
|
+
has_hev=product_info.has_hev,
|
|
1627
|
+
has_extended_multizone=product_info.has_extended_multizone,
|
|
1628
|
+
kelvin_min=(
|
|
1629
|
+
product_info.temperature_range.min
|
|
1630
|
+
if product_info.temperature_range
|
|
1631
|
+
else None
|
|
1632
|
+
),
|
|
1633
|
+
kelvin_max=(
|
|
1634
|
+
product_info.temperature_range.max
|
|
1635
|
+
if product_info.temperature_range
|
|
1636
|
+
else None
|
|
1637
|
+
),
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
async def _initialize_state(self) -> StateT:
|
|
1641
|
+
"""Initialize device state transactionally.
|
|
1642
|
+
|
|
1643
|
+
Fetches all required device state in parallel and creates the state instance.
|
|
1644
|
+
This is an all-or-nothing operation - either all state is fetched successfully
|
|
1645
|
+
or an exception is raised.
|
|
1646
|
+
|
|
1647
|
+
Raises:
|
|
1648
|
+
LifxTimeoutError: If device does not respond within timeout
|
|
1649
|
+
LifxDeviceNotFoundError: If device cannot be reached
|
|
1650
|
+
LifxProtocolError: If responses are invalid
|
|
1651
|
+
"""
|
|
1652
|
+
# Ensure capabilities are loaded
|
|
1653
|
+
await self._ensure_capabilities()
|
|
1654
|
+
capabilities = self._create_capabilities()
|
|
1655
|
+
|
|
1656
|
+
# Fetch semi-static and volatile state in parallel
|
|
1657
|
+
# get_color returns color, power, and label in one request
|
|
1658
|
+
async with asyncio.TaskGroup() as tg:
|
|
1659
|
+
label_task = tg.create_task(self.get_label())
|
|
1660
|
+
power_task = tg.create_task(self.get_power())
|
|
1661
|
+
host_fw_task = tg.create_task(self.get_host_firmware())
|
|
1662
|
+
wifi_fw_task = tg.create_task(self.get_wifi_firmware())
|
|
1663
|
+
location_task = tg.create_task(self.get_location())
|
|
1664
|
+
group_task = tg.create_task(self.get_group())
|
|
1665
|
+
|
|
1666
|
+
# Extract results
|
|
1667
|
+
label = label_task.result()
|
|
1668
|
+
power = power_task.result()
|
|
1669
|
+
host_firmware = host_fw_task.result()
|
|
1670
|
+
wifi_firmware = wifi_fw_task.result()
|
|
1671
|
+
location_info = location_task.result()
|
|
1672
|
+
group_info = group_task.result()
|
|
1673
|
+
|
|
1674
|
+
# Get MAC address (already calculated in get_host_firmware)
|
|
1675
|
+
mac_address = await self.get_mac_address()
|
|
1676
|
+
|
|
1677
|
+
# Get model name
|
|
1678
|
+
assert self._capabilities is not None
|
|
1679
|
+
model = self._capabilities.name
|
|
1680
|
+
|
|
1681
|
+
# Create state instance
|
|
1682
|
+
# Cast is needed because when Device is used directly, StateT = DeviceState
|
|
1683
|
+
# Subclasses override this method to create their specific state type
|
|
1684
|
+
self._state = cast(
|
|
1685
|
+
StateT,
|
|
1686
|
+
DeviceState(
|
|
1687
|
+
model=model,
|
|
1688
|
+
label=label,
|
|
1689
|
+
serial=self.serial,
|
|
1690
|
+
mac_address=mac_address,
|
|
1691
|
+
capabilities=capabilities,
|
|
1692
|
+
power=power,
|
|
1693
|
+
host_firmware=host_firmware,
|
|
1694
|
+
wifi_firmware=wifi_firmware,
|
|
1695
|
+
location=location_info,
|
|
1696
|
+
group=group_info,
|
|
1697
|
+
last_updated=time.time(),
|
|
1698
|
+
),
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
return self._state
|
|
1702
|
+
|
|
1703
|
+
async def refresh_state(self) -> None:
|
|
1704
|
+
"""Refresh device state from hardware.
|
|
1705
|
+
|
|
1706
|
+
Fetches current state from device and updates the state instance.
|
|
1707
|
+
Base implementation fetches label, power, and updates timestamp.
|
|
1708
|
+
Subclasses override to add device-specific state updates.
|
|
1709
|
+
|
|
1710
|
+
Raises:
|
|
1711
|
+
RuntimeError: If state has not been initialized
|
|
1712
|
+
LifxTimeoutError: If device does not respond
|
|
1713
|
+
LifxDeviceNotFoundError: If device cannot be reached
|
|
1714
|
+
"""
|
|
1715
|
+
if not self._state:
|
|
1716
|
+
await self._initialize_state()
|
|
1717
|
+
return
|
|
1718
|
+
|
|
1719
|
+
async def _schedule_refresh(self) -> None:
|
|
1720
|
+
"""Schedule debounced state refresh.
|
|
1721
|
+
|
|
1722
|
+
Schedules a refresh task that waits for STATE_REFRESH_DEBOUNCE_MS milliseconds
|
|
1723
|
+
before executing. If another refresh is scheduled before the delay expires,
|
|
1724
|
+
the previous task is cancelled and a new one is scheduled.
|
|
1725
|
+
|
|
1726
|
+
This ensures that rapid state changes only trigger one refresh.
|
|
1727
|
+
"""
|
|
1728
|
+
from lifx.const import STATE_REFRESH_DEBOUNCE_MS
|
|
1729
|
+
|
|
1730
|
+
if self._is_closed:
|
|
1731
|
+
return
|
|
1732
|
+
|
|
1733
|
+
# Cancel existing refresh task if running
|
|
1734
|
+
if self._refresh_task and not self._refresh_task.done():
|
|
1735
|
+
self._refresh_task.cancel()
|
|
1736
|
+
try:
|
|
1737
|
+
await self._refresh_task
|
|
1738
|
+
except asyncio.CancelledError:
|
|
1739
|
+
pass
|
|
1740
|
+
|
|
1741
|
+
# Schedule new refresh task
|
|
1742
|
+
async def _debounced_refresh() -> None:
|
|
1743
|
+
try:
|
|
1744
|
+
await asyncio.sleep(STATE_REFRESH_DEBOUNCE_MS / 1000.0)
|
|
1745
|
+
if not self._is_closed:
|
|
1746
|
+
async with self._refresh_lock:
|
|
1747
|
+
await self.refresh_state()
|
|
1748
|
+
except asyncio.CancelledError:
|
|
1749
|
+
pass
|
|
1750
|
+
except Exception as e:
|
|
1751
|
+
_LOGGER.warning(
|
|
1752
|
+
{
|
|
1753
|
+
"class": "Device",
|
|
1754
|
+
"method": "_schedule_refresh",
|
|
1755
|
+
"action": "refresh_failed",
|
|
1756
|
+
"error": str(e),
|
|
1757
|
+
}
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
self._refresh_task = asyncio.create_task(_debounced_refresh())
|
|
1761
|
+
|
|
1252
1762
|
@property
|
|
1253
1763
|
def label(self) -> str | None:
|
|
1254
1764
|
"""Get cached label if available.
|
|
@@ -1258,7 +1768,11 @@ class Device:
|
|
|
1258
1768
|
Returns:
|
|
1259
1769
|
Device label or None if never fetched.
|
|
1260
1770
|
"""
|
|
1261
|
-
|
|
1771
|
+
if self._state is not None:
|
|
1772
|
+
return self._state.label
|
|
1773
|
+
elif self._label is not None:
|
|
1774
|
+
return self._label
|
|
1775
|
+
return None
|
|
1262
1776
|
|
|
1263
1777
|
@property
|
|
1264
1778
|
def version(self) -> DeviceVersion | None:
|
|
@@ -1280,7 +1794,11 @@ class Device:
|
|
|
1280
1794
|
Returns:
|
|
1281
1795
|
Firmware info or None if never fetched.
|
|
1282
1796
|
"""
|
|
1283
|
-
|
|
1797
|
+
if self._state is not None:
|
|
1798
|
+
return self._state.host_firmware
|
|
1799
|
+
elif self._host_firmware is not None:
|
|
1800
|
+
return self._host_firmware
|
|
1801
|
+
return None
|
|
1284
1802
|
|
|
1285
1803
|
@property
|
|
1286
1804
|
def wifi_firmware(self) -> FirmwareInfo | None:
|
|
@@ -1291,7 +1809,11 @@ class Device:
|
|
|
1291
1809
|
Returns:
|
|
1292
1810
|
Firmware info or None if never fetched.
|
|
1293
1811
|
"""
|
|
1294
|
-
|
|
1812
|
+
if self._state is not None:
|
|
1813
|
+
return self._state.wifi_firmware
|
|
1814
|
+
elif self._wifi_firmware is not None:
|
|
1815
|
+
return self._wifi_firmware
|
|
1816
|
+
return None
|
|
1295
1817
|
|
|
1296
1818
|
@property
|
|
1297
1819
|
def location(self) -> str | None:
|
|
@@ -1302,7 +1824,9 @@ class Device:
|
|
|
1302
1824
|
Returns:
|
|
1303
1825
|
Location name or None if never fetched.
|
|
1304
1826
|
"""
|
|
1305
|
-
if self.
|
|
1827
|
+
if self._state is not None:
|
|
1828
|
+
return self._state.location_name
|
|
1829
|
+
elif self._location is not None:
|
|
1306
1830
|
return self._location.label
|
|
1307
1831
|
return None
|
|
1308
1832
|
|
|
@@ -1315,7 +1839,9 @@ class Device:
|
|
|
1315
1839
|
Returns:
|
|
1316
1840
|
Group name or None if never fetched.
|
|
1317
1841
|
"""
|
|
1318
|
-
if self.
|
|
1842
|
+
if self._state is not None:
|
|
1843
|
+
return self._state.group_name
|
|
1844
|
+
elif self._group is not None:
|
|
1319
1845
|
return self._group.label
|
|
1320
1846
|
return None
|
|
1321
1847
|
|
|
@@ -1326,7 +1852,9 @@ class Device:
|
|
|
1326
1852
|
Returns:
|
|
1327
1853
|
Model string from product registry.
|
|
1328
1854
|
"""
|
|
1329
|
-
if self.
|
|
1855
|
+
if self._state is not None:
|
|
1856
|
+
return self._state.model
|
|
1857
|
+
elif self.capabilities is not None:
|
|
1330
1858
|
return self.capabilities.name
|
|
1331
1859
|
return None
|
|
1332
1860
|
|
|
@@ -1340,7 +1868,11 @@ class Device:
|
|
|
1340
1868
|
MAC address in colon-separated format (e.g., "d0:73:d5:01:02:03"),
|
|
1341
1869
|
or None if not yet calculated.
|
|
1342
1870
|
"""
|
|
1343
|
-
|
|
1871
|
+
if self._state is not None:
|
|
1872
|
+
return self._state.mac_address
|
|
1873
|
+
elif self._mac_address is not None:
|
|
1874
|
+
return self._mac_address
|
|
1875
|
+
return None
|
|
1344
1876
|
|
|
1345
1877
|
def __repr__(self) -> str:
|
|
1346
1878
|
"""String representation of device."""
|