lifx-async 4.9.0__py3-none-any.whl → 5.0.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/api.py CHANGED
@@ -198,9 +198,7 @@ class DeviceGroup:
198
198
  await group.set_power(True, duration=1.0)
199
199
  ```
200
200
  """
201
- async with asyncio.TaskGroup() as tg:
202
- for light in self.lights:
203
- tg.create_task(light.set_power(on, duration))
201
+ await asyncio.gather(*(light.set_power(on, duration) for light in self.lights))
204
202
 
205
203
  async def set_color(self, color: HSBK, duration: float = 0.0) -> None:
206
204
  """Set color for all Light devices in the group.
@@ -218,9 +216,9 @@ class DeviceGroup:
218
216
  await group.set_color(HSBK.from_rgb(255, 0, 0), duration=2.0)
219
217
  ```
220
218
  """
221
- async with asyncio.TaskGroup() as tg:
222
- for light in self.lights:
223
- tg.create_task(light.set_color(color, duration))
219
+ await asyncio.gather(
220
+ *(light.set_color(color, duration) for light in self.lights)
221
+ )
224
222
 
225
223
  async def set_brightness(self, brightness: float, duration: float = 0.0) -> None:
226
224
  """Set brightness for all Light devices in the group.
@@ -238,9 +236,9 @@ class DeviceGroup:
238
236
  await group.set_brightness(0.5, duration=1.0)
239
237
  ```
240
238
  """
241
- async with asyncio.TaskGroup() as tg:
242
- for light in self.lights:
243
- tg.create_task(light.set_brightness(brightness, duration))
239
+ await asyncio.gather(
240
+ *(light.set_brightness(brightness, duration) for light in self.lights)
241
+ )
244
242
 
245
243
  async def pulse(
246
244
  self, color: HSBK, period: float = 1.0, cycles: float = 1.0
@@ -261,9 +259,9 @@ class DeviceGroup:
261
259
  await group.pulse(Colors.RED, period=1.0, cycles=1.0)
262
260
  ```
263
261
  """
264
- async with asyncio.TaskGroup() as tg:
265
- for light in self.lights:
266
- tg.create_task(light.pulse(color, period, cycles))
262
+ await asyncio.gather(
263
+ *(light.pulse(color, period, cycles) for light in self.lights)
264
+ )
267
265
 
268
266
  # Location and Group Organization Methods
269
267
 
@@ -280,14 +278,13 @@ class DeviceGroup:
280
278
  )
281
279
 
282
280
  # Fetch all location info concurrently
283
- tasks: dict[str, asyncio.Task[CollectionInfo | None]] = {}
284
- async with asyncio.TaskGroup() as tg:
285
- for device in self._devices:
286
- tasks[device.serial] = tg.create_task(device.get_location())
281
+ location_results = await asyncio.gather(
282
+ *(device.get_location() for device in self._devices)
283
+ )
287
284
 
288
- results: list[tuple[Device, CollectionInfo | None]] = []
289
- for device in self._devices:
290
- results.append((device, tasks[device.serial].result()))
285
+ results: list[tuple[Device, CollectionInfo | None]] = list(
286
+ zip(self._devices, location_results)
287
+ )
291
288
 
292
289
  # Group by location UUID
293
290
  for device, location_info in results:
@@ -332,15 +329,14 @@ class DeviceGroup:
332
329
  # Collect group info from all devices concurrently
333
330
  group_data: dict[str, list[tuple[Device, CollectionInfo]]] = defaultdict(list)
334
331
 
335
- tasks: dict[str, asyncio.Task[CollectionInfo | None]] = {}
336
- async with asyncio.TaskGroup() as tg:
337
- for device in self._devices:
338
- tasks[device.serial] = tg.create_task(device.get_group())
339
-
340
332
  # Fetch all group info concurrently
341
- results: list[tuple[Device, CollectionInfo | None]] = []
342
- for device in self._devices:
343
- results.append((device, tasks[device.serial].result()))
333
+ group_results = await asyncio.gather(
334
+ *(device.get_group() for device in self._devices)
335
+ )
336
+
337
+ results: list[tuple[Device, CollectionInfo | None]] = list(
338
+ zip(self._devices, group_results)
339
+ )
344
340
 
345
341
  # Group by group UUID
346
342
  for device, group_info in results:
@@ -712,18 +708,20 @@ class DeviceGroup:
712
708
  await group.apply_theme(evening, power_on=True, duration=1.0)
713
709
  ```
714
710
  """
715
- async with asyncio.TaskGroup() as tg:
711
+ await asyncio.gather(
716
712
  # Apply theme to all lights
717
- for light in self.lights:
718
- tg.create_task(light.apply_theme(theme, power_on, duration))
719
-
713
+ *(light.apply_theme(theme, power_on, duration) for light in self.lights),
720
714
  # Apply theme to all multizone lights
721
- for multizone in self.multizone_lights:
722
- tg.create_task(multizone.apply_theme(theme, power_on, duration))
723
-
715
+ *(
716
+ multizone.apply_theme(theme, power_on, duration)
717
+ for multizone in self.multizone_lights
718
+ ),
724
719
  # Apply theme to all matrix light devices
725
- for matrix in self.matrix_lights:
726
- tg.create_task(matrix.apply_theme(theme, power_on, duration))
720
+ *(
721
+ matrix.apply_theme(theme, power_on, duration)
722
+ for matrix in self.matrix_lights
723
+ ),
724
+ )
727
725
 
728
726
  def invalidate_metadata_cache(self) -> None:
729
727
  """Clear all cached location and group metadata.
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 TYPE_CHECKING, Generic, Self, TypeVar, cast
12
+ from typing import TYPE_CHECKING, Generic, TypeVar, cast
13
13
 
14
14
  from lifx.const import (
15
15
  DEFAULT_MAX_RETRIES,
@@ -26,6 +26,8 @@ from lifx.protocol import packets
26
26
  from lifx.protocol.models import Serial
27
27
 
28
28
  if TYPE_CHECKING:
29
+ from typing_extensions import Self
30
+
29
31
  from lifx.devices import (
30
32
  CeilingLight,
31
33
  HevLight,
@@ -681,12 +683,13 @@ class Device(Generic[StateT]):
681
683
  async def _setup(self) -> None:
682
684
  """Populate device capabilities, state and metadata."""
683
685
  await self._ensure_capabilities()
684
- async with asyncio.TaskGroup() as tg:
685
- tg.create_task(self.get_host_firmware())
686
- tg.create_task(self.get_wifi_firmware())
687
- tg.create_task(self.get_label())
688
- tg.create_task(self.get_location())
689
- tg.create_task(self.get_group())
686
+ await asyncio.gather(
687
+ self.get_host_firmware(),
688
+ self.get_wifi_firmware(),
689
+ self.get_label(),
690
+ self.get_location(),
691
+ self.get_group(),
692
+ )
690
693
 
691
694
  async def get_mac_address(self) -> str:
692
695
  """Calculate and return the MAC address for this device."""
@@ -1669,21 +1672,21 @@ class Device(Generic[StateT]):
1669
1672
 
1670
1673
  # Fetch semi-static and volatile state in parallel
1671
1674
  # get_color returns color, power, and label in one request
1672
- async with asyncio.TaskGroup() as tg:
1673
- label_task = tg.create_task(self.get_label())
1674
- power_task = tg.create_task(self.get_power())
1675
- host_fw_task = tg.create_task(self.get_host_firmware())
1676
- wifi_fw_task = tg.create_task(self.get_wifi_firmware())
1677
- location_task = tg.create_task(self.get_location())
1678
- group_task = tg.create_task(self.get_group())
1679
-
1680
- # Extract results
1681
- label = label_task.result()
1682
- power = power_task.result()
1683
- host_firmware = host_fw_task.result()
1684
- wifi_firmware = wifi_fw_task.result()
1685
- location_info = location_task.result()
1686
- group_info = group_task.result()
1675
+ (
1676
+ label,
1677
+ power,
1678
+ host_firmware,
1679
+ wifi_firmware,
1680
+ location_info,
1681
+ group_info,
1682
+ ) = await asyncio.gather(
1683
+ self.get_label(),
1684
+ self.get_power(),
1685
+ self.get_host_firmware(),
1686
+ self.get_wifi_firmware(),
1687
+ self.get_location(),
1688
+ self.get_group(),
1689
+ )
1687
1690
 
1688
1691
  # Get MAC address (already calculated in get_host_firmware)
1689
1692
  mac_address = await self.get_mac_address()
lifx/devices/hev.py CHANGED
@@ -119,10 +119,11 @@ class HevLight(Light):
119
119
  async def _setup(self) -> None:
120
120
  """Populate HEV light capabilities, state and metadata."""
121
121
  await super()._setup()
122
- async with asyncio.TaskGroup() as tg:
123
- tg.create_task(self.get_hev_config())
124
- tg.create_task(self.get_hev_cycle())
125
- tg.create_task(self.get_last_hev_result())
122
+ await asyncio.gather(
123
+ self.get_hev_config(),
124
+ self.get_hev_cycle(),
125
+ self.get_last_hev_result(),
126
+ )
126
127
 
127
128
  async def get_hev_cycle(self) -> HevCycleState:
128
129
  """Get current HEV cycle state.
@@ -413,12 +414,10 @@ class HevLight(Light):
413
414
  await super().refresh_state()
414
415
 
415
416
  # Fetch all HEV light state
416
- async with asyncio.TaskGroup() as tg:
417
- hev_cycle_task = tg.create_task(self.get_hev_cycle())
418
- hev_result_task = tg.create_task(self.get_last_hev_result())
419
-
420
- hev_cycle = hev_cycle_task.result()
421
- hev_result = hev_result_task.result()
417
+ hev_cycle, hev_result = await asyncio.gather(
418
+ self.get_hev_cycle(),
419
+ self.get_last_hev_result(),
420
+ )
422
421
 
423
422
  self._state.hev_cycle = hev_cycle
424
423
  self._state.hev_result = hev_result
@@ -440,14 +439,11 @@ class HevLight(Light):
440
439
 
441
440
  # Fetch semi-static and volatile state in parallel
442
441
  # get_color returns color, power, and label in one request
443
- async with asyncio.TaskGroup() as tg:
444
- hev_cycle_task = tg.create_task(self.get_hev_cycle())
445
- hev_config_task = tg.create_task(self.get_hev_config())
446
- hev_result_task = tg.create_task(self.get_last_hev_result())
447
-
448
- hev_cycle = hev_cycle_task.result()
449
- hev_config = hev_config_task.result()
450
- hev_result = hev_result_task.result()
442
+ hev_cycle, hev_config, hev_result = await asyncio.gather(
443
+ self.get_hev_cycle(),
444
+ self.get_hev_config(),
445
+ self.get_last_hev_result(),
446
+ )
451
447
 
452
448
  # Create state instance with HEV fields
453
449
  self._state = HevLightState.from_light_state(
lifx/devices/light.py CHANGED
@@ -964,19 +964,19 @@ class Light(Device[LightState]):
964
964
  # Fetch semi-static and volatile state in parallel
965
965
  # get_color returns color, power, and label in one request
966
966
  try:
967
- async with asyncio.TaskGroup() as tg:
968
- color_task = tg.create_task(self.get_color())
969
- host_fw_task = tg.create_task(self.get_host_firmware())
970
- wifi_fw_task = tg.create_task(self.get_wifi_firmware())
971
- location_task = tg.create_task(self.get_location())
972
- group_task = tg.create_task(self.get_group())
973
-
974
- # Extract results
975
- color, power, label = color_task.result()
976
- host_firmware = host_fw_task.result()
977
- wifi_firmware = wifi_fw_task.result()
978
- location_info = location_task.result()
979
- group_info = group_task.result()
967
+ (
968
+ (color, power, label),
969
+ host_firmware,
970
+ wifi_firmware,
971
+ location_info,
972
+ group_info,
973
+ ) = await asyncio.gather(
974
+ self.get_color(),
975
+ self.get_host_firmware(),
976
+ self.get_wifi_firmware(),
977
+ self.get_location(),
978
+ self.get_group(),
979
+ )
980
980
 
981
981
  # Get MAC address (already calculated in get_host_firmware)
982
982
  mac_address = await self.get_mac_address()
@@ -1003,7 +1003,7 @@ class Light(Device[LightState]):
1003
1003
 
1004
1004
  return self._state
1005
1005
 
1006
- except* LifxTimeoutError:
1006
+ except LifxTimeoutError:
1007
1007
  raise LifxTimeoutError(f"Error initializing state for {self.serial}")
1008
- except* LifxError:
1008
+ except LifxError:
1009
1009
  raise LifxError(f"Error initializing state for {self.serial}")
lifx/devices/multizone.py CHANGED
@@ -969,12 +969,10 @@ class MultiZoneLight(Light):
969
969
  """
970
970
  await super().refresh_state()
971
971
 
972
- async with asyncio.TaskGroup() as tg:
973
- zones_task = tg.create_task(self.get_all_color_zones())
974
- effect_task = tg.create_task(self.get_effect())
975
-
976
- zones = zones_task.result()
977
- effect = effect_task.result()
972
+ zones, effect = await asyncio.gather(
973
+ self.get_all_color_zones(),
974
+ self.get_effect(),
975
+ )
978
976
 
979
977
  self._state.zones = zones
980
978
  self._state.effect = effect.effect_type
@@ -994,12 +992,10 @@ class MultiZoneLight(Light):
994
992
  """
995
993
  light_state = await super()._initialize_state()
996
994
 
997
- async with asyncio.TaskGroup() as tg:
998
- zones_task = tg.create_task(self.get_all_color_zones())
999
- effect_task = tg.create_task(self.get_effect())
1000
-
1001
- zones = zones_task.result()
1002
- effect = effect_task.result()
995
+ zones, effect = await asyncio.gather(
996
+ self.get_all_color_zones(),
997
+ self.get_effect(),
998
+ )
1003
999
 
1004
1000
  self._state = MultiZoneLightState.from_light_state(
1005
1001
  light_state=light_state, zones=zones, effect=effect.effect_type
lifx/effects/base.py CHANGED
@@ -85,22 +85,26 @@ class LIFXEffect(ABC):
85
85
  if self.power_on:
86
86
  needs_power_on = False
87
87
 
88
- async def power_on_if_needed(light: Light) -> None:
89
- """Power on a single light if it's currently off."""
90
- nonlocal needs_power_on
88
+ async def power_on_if_needed(light: Light) -> bool:
89
+ """Power on a single light if it's currently off.
90
+
91
+ Returns True if the light was powered on.
92
+ """
91
93
  is_on = await light.get_power()
92
94
  if not is_on:
93
- needs_power_on = True
94
95
  # Get startup color for this light
95
96
  startup_color = await self.from_poweroff_hsbk(light)
96
97
  # Set color immediately, then power on
97
98
  await light.set_color(startup_color, duration=0)
98
99
  await light.set_power(True, duration=POWER_ON_TRANSITION_DURATION)
100
+ return True
101
+ return False
99
102
 
100
103
  # Power on all lights concurrently
101
- async with asyncio.TaskGroup() as tg:
102
- for light in self.participants:
103
- tg.create_task(power_on_if_needed(light))
104
+ results = await asyncio.gather(
105
+ *(power_on_if_needed(light) for light in self.participants)
106
+ )
107
+ needs_power_on = any(results)
104
108
 
105
109
  # Wait for power transition to complete if any lights were powered on
106
110
  if needs_power_on:
lifx/effects/colorloop.py CHANGED
@@ -353,17 +353,11 @@ class EffectColorloop(LIFXEffect):
353
353
  )
354
354
 
355
355
  # Fetch colors for all lights concurrently
356
- colors: list[HSBK] = [None] * len(self.participants) # type: ignore
357
-
358
- async with asyncio.TaskGroup() as tg:
359
- for idx, light in enumerate(self.participants):
360
-
361
- async def fetch_and_store(index: int, device: Light) -> None:
362
- colors[index] = await get_color_for_light(device)
363
-
364
- tg.create_task(fetch_and_store(idx, light))
356
+ colors = await asyncio.gather(
357
+ *(get_color_for_light(light) for light in self.participants)
358
+ )
365
359
 
366
- return colors
360
+ return list(colors)
367
361
 
368
362
  async def from_poweroff_hsbk(self, _light: Light) -> HSBK:
369
363
  """Return startup color when light is powered off.
lifx/effects/conductor.py CHANGED
@@ -154,36 +154,32 @@ class Conductor:
154
154
 
155
155
  # Capture prestates in parallel for all lights that need it
156
156
  if lights_needing_capture:
157
- captured: list[tuple[str, PreState]] = [None] * len( # type: ignore
158
- lights_needing_capture
159
- )
160
157
 
161
- async with asyncio.TaskGroup() as tg:
162
- for capture_idx, (_, light) in enumerate(lights_needing_capture):
163
-
164
- async def capture_and_store(cidx: int, device: Light) -> None:
165
- prestate = await self._state_manager.capture_state(device)
166
- captured[cidx] = (device.serial, prestate)
167
- _LOGGER.debug(
168
- {
169
- "class": self.__class__.__name__,
170
- "method": "start",
171
- "action": "capture",
172
- "values": {
173
- "serial": device.serial,
174
- "power": prestate.power,
175
- "color": {
176
- "hue": prestate.color.hue,
177
- "saturation": prestate.color.saturation,
178
- "brightness": prestate.color.brightness,
179
- "kelvin": prestate.color.kelvin,
180
- },
181
- "has_zones": prestate.zone_colors is not None,
182
- },
183
- }
184
- )
185
-
186
- tg.create_task(capture_and_store(capture_idx, light))
158
+ async def capture_and_log(device: Light) -> tuple[str, PreState]:
159
+ prestate = await self._state_manager.capture_state(device)
160
+ _LOGGER.debug(
161
+ {
162
+ "class": self.__class__.__name__,
163
+ "method": "start",
164
+ "action": "capture",
165
+ "values": {
166
+ "serial": device.serial,
167
+ "power": prestate.power,
168
+ "color": {
169
+ "hue": prestate.color.hue,
170
+ "saturation": prestate.color.saturation,
171
+ "brightness": prestate.color.brightness,
172
+ "kelvin": prestate.color.kelvin,
173
+ },
174
+ "has_zones": prestate.zone_colors is not None,
175
+ },
176
+ }
177
+ )
178
+ return (device.serial, prestate)
179
+
180
+ captured = await asyncio.gather(
181
+ *(capture_and_log(light) for _, light in lights_needing_capture)
182
+ )
187
183
 
188
184
  # Store captured prestates
189
185
  for serial, prestate in captured:
@@ -257,11 +253,12 @@ class Conductor:
257
253
  async with self._lock:
258
254
  # Restore all lights in parallel
259
255
  if lights_to_restore:
260
- async with asyncio.TaskGroup() as tg:
261
- for light, prestate in lights_to_restore:
262
- tg.create_task(
263
- self._state_manager.restore_state(light, prestate)
264
- )
256
+ await asyncio.gather(
257
+ *(
258
+ self._state_manager.restore_state(light, prestate)
259
+ for light, prestate in lights_to_restore
260
+ )
261
+ )
265
262
 
266
263
  # Remove from running registry after restoration
267
264
  for light in lights:
@@ -304,11 +301,12 @@ class Conductor:
304
301
 
305
302
  # Restore all lights in parallel
306
303
  if lights_to_restore:
307
- async with asyncio.TaskGroup() as tg:
308
- for light, prestate in lights_to_restore:
309
- tg.create_task(
310
- self._state_manager.restore_state(light, prestate)
311
- )
304
+ await asyncio.gather(
305
+ *(
306
+ self._state_manager.restore_state(light, prestate)
307
+ for light, prestate in lights_to_restore
308
+ )
309
+ )
312
310
 
313
311
  # Remove from running registry after restoration
314
312
  for light in participants:
@@ -367,10 +365,9 @@ class Conductor:
367
365
  Returns:
368
366
  List of lights compatible with the effect
369
367
  """
370
- # Check all lights in parallel using effect's compatibility check
371
- results: list[tuple[Light, bool]] = []
372
368
 
373
- async def check_and_store(light: Light) -> None:
369
+ # Check all lights in parallel using effect's compatibility check
370
+ async def check_compatibility(light: Light) -> tuple[Light, bool]:
374
371
  """Check if a single light is compatible with the effect."""
375
372
  is_compatible = await effect.is_light_compatible(light)
376
373
 
@@ -388,11 +385,11 @@ class Conductor:
388
385
  }
389
386
  )
390
387
 
391
- results.append((light, is_compatible))
388
+ return (light, is_compatible)
392
389
 
393
- async with asyncio.TaskGroup() as tg:
394
- for light in participants:
395
- tg.create_task(check_and_store(light))
390
+ results = await asyncio.gather(
391
+ *(check_compatibility(light) for light in participants)
392
+ )
396
393
 
397
394
  # Filter to only compatible lights
398
395
  compatible = [light for light, is_compatible in results if is_compatible]
lifx/effects/pulse.py CHANGED
@@ -184,17 +184,11 @@ class EffectPulse(LIFXEffect):
184
184
  return await self.fetch_light_color(light)
185
185
 
186
186
  # Fetch colors for all lights concurrently
187
- colors: list[HSBK] = [None] * len(self.participants) # type: ignore
188
-
189
- async with asyncio.TaskGroup() as tg:
190
- for idx, light in enumerate(self.participants):
191
-
192
- async def fetch_and_store(index: int, device: Light) -> None:
193
- colors[index] = await get_color_for_light(device)
194
-
195
- tg.create_task(fetch_and_store(idx, light))
187
+ colors = await asyncio.gather(
188
+ *(get_color_for_light(light) for light in self.participants)
189
+ )
196
190
 
197
- return colors
191
+ return list(colors)
198
192
 
199
193
  async def _apply_waveform(self, light: Light, color: HSBK) -> None:
200
194
  """Apply waveform to a single light.
@@ -11,7 +11,7 @@ from collections.abc import AsyncGenerator
11
11
  from typing import TYPE_CHECKING, Any, TypeVar
12
12
 
13
13
  if TYPE_CHECKING:
14
- from typing import Self
14
+ from typing_extensions import Self
15
15
 
16
16
  from lifx.const import (
17
17
  DEFAULT_MAX_RETRIES,
@@ -255,9 +255,10 @@ class MdnsTransport:
255
255
  raise LifxNetworkError("Socket not open")
256
256
 
257
257
  try:
258
- async with asyncio.timeout(timeout):
259
- data, addr = await self._protocol.queue.get()
260
- return data, addr
258
+ data, addr = await asyncio.wait_for(
259
+ self._protocol.queue.get(), timeout=timeout
260
+ )
261
+ return data, addr
261
262
  except TimeoutError as e:
262
263
  raise LifxTimeoutError(f"No mDNS data received within {timeout}s") from e
263
264
  except OSError as e:
lifx/network/transport.py CHANGED
@@ -205,43 +205,9 @@ class UdpTransport:
205
205
  raise LifxNetworkError("Socket not open")
206
206
 
207
207
  try:
208
- async with asyncio.timeout(timeout):
209
- data, addr = await self._protocol.queue.get()
210
-
211
- # Validate packet size
212
- if len(data) > MAX_PACKET_SIZE:
213
- from lifx.exceptions import LifxProtocolError
214
-
215
- _LOGGER.error(
216
- {
217
- "class": "UdpTransport",
218
- "method": "receive",
219
- "action": "packet_too_large",
220
- "packet_size": len(data),
221
- "max_size": MAX_PACKET_SIZE,
222
- }
223
- )
224
- raise LifxProtocolError(
225
- f"Packet too big: {len(data)} bytes > {MAX_PACKET_SIZE} bytes"
226
- )
227
-
228
- if len(data) < MIN_PACKET_SIZE:
229
- from lifx.exceptions import LifxProtocolError
230
-
231
- _LOGGER.error(
232
- {
233
- "class": "UdpTransport",
234
- "method": "receive",
235
- "action": "packet_too_small",
236
- "packet_size": len(data),
237
- "min_size": MIN_PACKET_SIZE,
238
- }
239
- )
240
- raise LifxProtocolError(
241
- f"Packet too small: {len(data)} bytes < {MIN_PACKET_SIZE} bytes"
242
- )
243
-
244
- return data, addr
208
+ data, addr = await asyncio.wait_for(
209
+ self._protocol.queue.get(), timeout=timeout
210
+ )
245
211
  except TimeoutError as e:
246
212
  raise LifxTimeoutError(f"No data received within {timeout}s") from e
247
213
  except OSError as e:
@@ -255,6 +221,41 @@ class UdpTransport:
255
221
  )
256
222
  raise LifxNetworkError(f"Failed to receive data: {e}") from e
257
223
 
224
+ # Validate packet size
225
+ if len(data) > MAX_PACKET_SIZE:
226
+ from lifx.exceptions import LifxProtocolError
227
+
228
+ _LOGGER.error(
229
+ {
230
+ "class": "UdpTransport",
231
+ "method": "receive",
232
+ "action": "packet_too_large",
233
+ "packet_size": len(data),
234
+ "max_size": MAX_PACKET_SIZE,
235
+ }
236
+ )
237
+ raise LifxProtocolError(
238
+ f"Packet too big: {len(data)} bytes > {MAX_PACKET_SIZE} bytes"
239
+ )
240
+
241
+ if len(data) < MIN_PACKET_SIZE:
242
+ from lifx.exceptions import LifxProtocolError
243
+
244
+ _LOGGER.error(
245
+ {
246
+ "class": "UdpTransport",
247
+ "method": "receive",
248
+ "action": "packet_too_small",
249
+ "packet_size": len(data),
250
+ "min_size": MIN_PACKET_SIZE,
251
+ }
252
+ )
253
+ raise LifxProtocolError(
254
+ f"Packet too small: {len(data)} bytes < {MIN_PACKET_SIZE} bytes"
255
+ )
256
+
257
+ return data, addr
258
+
258
259
  async def receive_many(
259
260
  self, timeout: float = 5.0, max_packets: int | None = None
260
261
  ) -> list[tuple[bytes, tuple[str, int]]]:
@@ -273,34 +274,40 @@ class UdpTransport:
273
274
  if self._protocol is None:
274
275
  raise LifxNetworkError("Socket not open")
275
276
 
277
+ import time
278
+
276
279
  packets: list[tuple[bytes, tuple[str, int]]] = []
280
+ deadline = time.monotonic() + timeout
277
281
 
278
- try:
279
- async with asyncio.timeout(timeout):
280
- while True:
281
- if max_packets is not None and len(packets) >= max_packets:
282
- break
283
-
284
- try:
285
- data, addr = await self._protocol.queue.get()
286
-
287
- # Validate packet size
288
- if len(data) > MAX_PACKET_SIZE:
289
- # Drop oversized packet to prevent memory exhaustion DoS
290
- continue
291
-
292
- if len(data) < MIN_PACKET_SIZE:
293
- # Drop undersized packet (header is 36 bytes)
294
- continue
295
-
296
- packets.append((data, addr))
297
- except OSError:
298
- # Ignore individual receive errors
299
- break
300
-
301
- except TimeoutError:
302
- # Timeout is expected - return what we collected
303
- pass
282
+ while True:
283
+ if max_packets is not None and len(packets) >= max_packets:
284
+ break
285
+
286
+ remaining = deadline - time.monotonic()
287
+ if remaining <= 0:
288
+ break
289
+
290
+ try:
291
+ data, addr = await asyncio.wait_for(
292
+ self._protocol.queue.get(), timeout=remaining
293
+ )
294
+
295
+ # Validate packet size
296
+ if len(data) > MAX_PACKET_SIZE:
297
+ # Drop oversized packet to prevent memory exhaustion DoS
298
+ continue
299
+
300
+ if len(data) < MIN_PACKET_SIZE:
301
+ # Drop undersized packet (header is 36 bytes)
302
+ continue
303
+
304
+ packets.append((data, addr))
305
+ except TimeoutError:
306
+ # Timeout is expected - return what we collected
307
+ break
308
+ except OSError:
309
+ # Ignore individual receive errors
310
+ break
304
311
 
305
312
  return packets
306
313
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 4.9.0
3
+ Version: 5.0.0
4
4
  Summary: A modern, type-safe, async Python library for controlling LIFX lights
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -11,6 +11,7 @@ Classifier: Framework :: Pytest
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Natural Language :: English
13
13
  Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.10
14
15
  Classifier: Programming Language :: Python :: 3.11
15
16
  Classifier: Programming Language :: Python :: 3.12
16
17
  Classifier: Programming Language :: Python :: 3.13
@@ -18,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Topic :: Software Development :: Libraries
19
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
21
  Classifier: Typing :: Typed
21
- Requires-Python: >=3.11
22
+ Requires-Python: >=3.10
22
23
  Description-Content-Type: text/markdown
23
24
 
24
25
  # lifx-async
@@ -1,34 +1,34 @@
1
1
  lifx/__init__.py,sha256=DKHG1vFJvPw_LpMkQgZN85gyOSD8dnceq6LnEGgR9vs,2810
2
- lifx/api.py,sha256=xHUM6NDgv8V8tLpDtpnPVJNGcVU5y_8da9wI3pnm06Y,35903
2
+ lifx/api.py,sha256=tTE9H8eSVrFIr6rEGxedWoR4ChnqvTz2EUgEQk60fgU,35428
3
3
  lifx/color.py,sha256=UfeoOiFgFih5edl2Ei-0wSzvZXRTI47yUm9GlNJZeTw,26041
4
4
  lifx/const.py,sha256=5LEh4h0-bEJlOfpG8fgyht0LkAEV9jkkpuCiuatBhEI,3840
5
5
  lifx/exceptions.py,sha256=pikAMppLn7gXyjiQVWM_tSvXKNh-g366nG_UWyqpHhc,815
6
6
  lifx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  lifx/devices/__init__.py,sha256=4b5QtO0EFWxIqN2lUYgM8uLjWyHI5hUcReiF9QCjCGw,1061
8
- lifx/devices/base.py,sha256=0G2PCJRNeIPkMCIw68x0ijn6gUIwh2jFlex8SN4Hs1Y,63530
8
+ lifx/devices/base.py,sha256=mhNLX6FoLBaZtYo9InleneYdb0dk3B2Ze8Z2eqXCNHo,63180
9
9
  lifx/devices/ceiling.py,sha256=bLAurvqTNmhKMFUUJmLqn1vDFawapYju2i4G0pHOH_4,45790
10
- lifx/devices/hev.py,sha256=T5hvt2q_vdgPBvThx_-M7n5pZu9pL0y9Fs3Zz_KL0NM,15588
10
+ lifx/devices/hev.py,sha256=kTRJRYnWyIY8Pkg_jOn978N-_1YXy9fRmBiGgEWscXw,15194
11
11
  lifx/devices/infrared.py,sha256=ePk9qxX_s-hv5gQMvio1Vv8FYiCd68HF0ySbWgSrvuU,8130
12
- lifx/devices/light.py,sha256=gk92lhViUWINGaxDWbs4qn8Stnn2fGCfRkC5Kk0Q-hI,34087
12
+ lifx/devices/light.py,sha256=ZhC7zuruZ9nzmnAR_st2KMUH8UNQAcNK-eQUYnKXm-8,33833
13
13
  lifx/devices/matrix.py,sha256=TcfVvqFCIhGXjOtaQEARQCXe9XqSFDZ4wEQdRZBiQpA,42301
14
- lifx/devices/multizone.py,sha256=zQj0qKxWccHAmkIpqIcGzZsKjCQL-_bAquz78o0Izng,33570
14
+ lifx/devices/multizone.py,sha256=7Te5Z_X9hDvdypjMqPGGM2TG0P9QltzFVi7UUxRdbGI,33326
15
15
  lifx/effects/__init__.py,sha256=4DF31yp7RJic5JoltMlz5dCtF5KQobU6NOUtLUKkVKE,1509
16
- lifx/effects/base.py,sha256=YO0Hbg2VYHKPtfYnWxmrtzYoPGOi9BUXhn8HVFKv5IM,10283
17
- lifx/effects/colorloop.py,sha256=kuuyENJS2irAN8vZAFsDa2guQdDbmmc4PJNiyZTfFPE,15840
18
- lifx/effects/conductor.py,sha256=0Aizn2gpo2kTqwSF4p9Qat8S4F53xwHJwVjOJONduKc,15036
16
+ lifx/effects/base.py,sha256=tKgX5PsV6hipffD2B236rOzudkMwWq59-eQGnfvNKdU,10354
17
+ lifx/effects/colorloop.py,sha256=Yz9XcQ_VhTPSnJn1s4WnkoXTRh2_qFJrPhQkIiTF6Tk,15574
18
+ lifx/effects/conductor.py,sha256=Oaq-6m1kdUF6bma_U9GcA9onZzh6YRjpExBr-OGHQJI,14552
19
19
  lifx/effects/const.py,sha256=03LfL8v9PtoUs6-2IR3aa6nkyA4Otdno51SFJtntC-U,795
20
20
  lifx/effects/models.py,sha256=MS5D-cxD0Ar8XhqbqKAc9q2sk38IP1vPkYwd8V7jCr8,2446
21
- lifx/effects/pulse.py,sha256=t5eyjfFWG1xT-RXKghRqHYJ9CG_50tPu4jsDapJZ2mw,8721
21
+ lifx/effects/pulse.py,sha256=k4dtBhhgVHyuwzqzx89jYVKbSRUVQdZj91cklyKarbE,8455
22
22
  lifx/effects/state_manager.py,sha256=iDfYowiCN5IJqcR1s-pM0mQEJpe-RDsMcOOSMmtPVDE,8983
23
23
  lifx/network/__init__.py,sha256=uSyA8r8qISG7qXUHbX8uk9A2E8rvDADgCcf94QIZ9so,499
24
- lifx/network/connection.py,sha256=aerPiYWf096lq8oBiS7JfE4k-P18GS50mNEC4TYa2g8,38401
24
+ lifx/network/connection.py,sha256=qPKEkhr9DvoL2cCJoIlDi8rowNS-gUn_F3mesnzyFjs,38412
25
25
  lifx/network/discovery.py,sha256=syFfkDYWo0AEoBdEBjWqBm4K7UJwZW5x2K0FBMiA2I0,24186
26
26
  lifx/network/message.py,sha256=jCLC9v0tbBi54g5CaHLFM_nP1Izu8kJmo2tt23HHBbA,2600
27
- lifx/network/transport.py,sha256=8QS0YV32rdP0EDiPEwuvZXbplRWL08pmjKybd87mkZ0,11070
27
+ lifx/network/transport.py,sha256=io_3SFYQliNa_upHcKRzrLUkDVsDmNsxa2gQMlxj7Zk,10912
28
28
  lifx/network/mdns/__init__.py,sha256=LlZgsFe6q5_SIXvXqtuZ_O9tJbcJZ-nsFkD2_wD8_TM,1412
29
29
  lifx/network/mdns/discovery.py,sha256=EZ2zlJmy96rMDmu5J-68ystXJ2gYa18zTYP3iqmTGgU,13200
30
30
  lifx/network/mdns/dns.py,sha256=OsvNSxLepIG3Nhw-kkQF3JrBYI-ikod5SHD2HO5_yGE,9363
31
- lifx/network/mdns/transport.py,sha256=k8gVZCvU-gksV2dV-jm2YG-_kuKWx0whtP3Va0EjCd8,10242
31
+ lifx/network/mdns/transport.py,sha256=41E_yX9Jx42ffElTcF-73A4ma48b3xkkHnG3DTcO8u8,10250
32
32
  lifx/network/mdns/types.py,sha256=9fhH5iuMQxLkFPhmFTf2-kOcUNoWEu7LrN15Qr9tFE0,990
33
33
  lifx/products/__init__.py,sha256=pf2O-fzt6nOrQd-wmzhiog91tMiGa-dDbaSNtU2ZQfE,764
34
34
  lifx/products/generator.py,sha256=DsTCJcEVPmn9sfXSbXYdFZjqMfIbodnIQL46DRASs0g,15731
@@ -47,7 +47,7 @@ lifx/theme/canvas.py,sha256=4h7lgN8iu_OdchObGDgbxTqQLCb-FRKC-M-YCWef_i4,8048
47
47
  lifx/theme/generators.py,sha256=nq3Yvntq_h-eFHbmmow3LcAdA_hEbRRaP5mv9Bydrjk,6435
48
48
  lifx/theme/library.py,sha256=tKlKZNqJp8lRGDnilWyDm_Qr1vCRGGwuvWVS82anNpQ,21326
49
49
  lifx/theme/theme.py,sha256=qMEx_8E41C0Cc6f083XHiAXEglTv4YlXW0UFsG1rQKg,5521
50
- lifx_async-4.9.0.dist-info/METADATA,sha256=w3SYjZDKKPvXksjmSm0DVfK2KHyFe-EnpgdLaUf27zk,2609
51
- lifx_async-4.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
52
- lifx_async-4.9.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
53
- lifx_async-4.9.0.dist-info/RECORD,,
50
+ lifx_async-5.0.0.dist-info/METADATA,sha256=Ozi93MbWMkhFydTJ_nuyQUxRLLH_iGYOxtlr0iBXe2U,2660
51
+ lifx_async-5.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
52
+ lifx_async-5.0.0.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
53
+ lifx_async-5.0.0.dist-info/RECORD,,