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/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 LocationInfo:
93
- """Device location information.
103
+ class CollectionInfo:
104
+ """Device location and group collection information.
94
105
 
95
106
  Attributes:
96
- location: Location UUID (16 bytes)
97
- label: Location label (up to 32 characters)
98
- updated_at: Timestamp when location was last updated (nanoseconds)
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
- location: bytes
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 GroupInfo:
108
- """Device group information.
123
+ class DeviceCapabilities:
124
+ """Device capabilities from product registry.
109
125
 
110
126
  Attributes:
111
- group: Group UUID (16 bytes)
112
- label: Group label (up to 32 characters)
113
- updated_at: Timestamp when group was last updated (nanoseconds)
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
- group: bytes
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
- updated_at: int
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
- class Device:
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: LocationInfo | None = None
288
- self._group: GroupInfo | None = None
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
- # No connection setup needed - connection pool handles everything
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 connection for explicit cleanup
372
- await self.connection.close()
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
- self._label = state.label
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": state.label},
796
+ "reply": {"label": label_value},
498
797
  }
499
798
  )
500
- return state.label
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
- # Update cached state
535
- self._label = label
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": state.level},
885
+ "reply": {"level": power_level},
576
886
  }
577
887
  )
578
- return state.level
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) -> LocationInfo:
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
- LocationInfo with location UUID, label, and updated timestamp
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.location.hex()}")
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 = LocationInfo(
872
- location=state.location,
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
- # Update cached state
1014
- location_info = LocationInfo(
1015
- location=location_uuid_to_use, label=label, updated_at=updated_at
1016
- )
1017
- self._location = location_info
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) -> GroupInfo:
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
- GroupInfo with group UUID, label, and updated timestamp
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.group.hex()}")
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 = GroupInfo(
1057
- group=state.group,
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
- "group": state.group.hex(),
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
- # Update cached state
1199
- group_info = GroupInfo(
1200
- group=group_uuid_to_use, label=label, updated_at=updated_at
1201
- )
1202
- self._group = group_info
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
- return self._label
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
- return self._host_firmware
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
- return self._wifi_firmware
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._location is not None:
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._group is not None:
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.capabilities is not None:
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
- return self._mac_address
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."""