lifx-async 4.3.2__py3-none-any.whl → 4.3.4__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
@@ -252,6 +252,8 @@ class Device:
252
252
  self.serial = serial_obj.to_string()
253
253
  self.ip = ip
254
254
  self.port = port
255
+ self._timeout = timeout
256
+ self._max_retries = max_retries
255
257
 
256
258
  # Create lightweight connection handle - connection pooling is internal
257
259
  self.connection = DeviceConnection(
@@ -304,10 +306,16 @@ class Device:
304
306
  ```
305
307
  """
306
308
  if serial is None:
307
- temp_conn = DeviceConnection(serial="000000000000", ip=ip, port=port)
309
+ temp_conn = DeviceConnection(
310
+ serial="000000000000",
311
+ ip=ip,
312
+ port=port,
313
+ timeout=timeout,
314
+ max_retries=max_retries,
315
+ )
308
316
  try:
309
317
  response = await temp_conn.request(
310
- packets.Device.GetService(), timeout=DISCOVERY_TIMEOUT
318
+ packets.Device.GetService(), timeout=timeout
311
319
  )
312
320
  if response and isinstance(response, packets.Device.StateService):
313
321
  if temp_conn.serial and temp_conn.serial != "000000000000":
@@ -890,9 +898,17 @@ class Device:
890
898
 
891
899
  try:
892
900
  # Check each device for the target label
893
- async for disc in discover_devices(timeout=discover_timeout):
901
+ async for disc in discover_devices(
902
+ timeout=discover_timeout,
903
+ device_timeout=self._timeout,
904
+ max_retries=self._max_retries,
905
+ ):
894
906
  temp_conn = DeviceConnection(
895
- serial=disc.serial, ip=disc.ip, port=disc.port
907
+ serial=disc.serial,
908
+ ip=disc.ip,
909
+ port=disc.port,
910
+ timeout=self._timeout,
911
+ max_retries=self._max_retries,
896
912
  )
897
913
 
898
914
  try:
@@ -1063,9 +1079,17 @@ class Device:
1063
1079
 
1064
1080
  try:
1065
1081
  # Check each device for the target label
1066
- async for disc in discover_devices(timeout=discover_timeout):
1082
+ async for disc in discover_devices(
1083
+ timeout=discover_timeout,
1084
+ device_timeout=self._timeout,
1085
+ max_retries=self._max_retries,
1086
+ ):
1067
1087
  temp_conn = DeviceConnection(
1068
- serial=disc.serial, ip=disc.ip, port=disc.port
1088
+ serial=disc.serial,
1089
+ ip=disc.ip,
1090
+ port=disc.port,
1091
+ timeout=self._timeout,
1092
+ max_retries=self._max_retries,
1069
1093
  )
1070
1094
 
1071
1095
  try:
lifx/devices/matrix.py CHANGED
@@ -371,45 +371,58 @@ class MatrixLight(Light):
371
371
 
372
372
  async def get64(
373
373
  self,
374
- tile_index: int,
375
- length: int,
376
- x: int,
377
- y: int,
378
- width: int,
379
- fb_index: int = 0,
374
+ tile_index: int = 0,
375
+ length: int = 1,
376
+ x: int = 0,
377
+ y: int = 0,
378
+ width: int | None = None,
380
379
  ) -> list[HSBK]:
381
380
  """Get up to 64 zones of color state from a tile.
382
381
 
382
+ For devices with ≤64 zones, returns all zones. For devices with >64 zones,
383
+ returns up to 64 zones due to protocol limitations.
384
+
383
385
  Args:
384
- tile_index: Index of the tile (0-based)
385
- length: Number of tiles to query (usually 1)
386
- x: X coordinate of the rectangle (0-based)
387
- y: Y coordinate of the rectangle (0-based)
388
- width: Width of the rectangle in zones
389
- fb_index: Frame buffer index (0 for display, 1 for temp buffer)
386
+ tile_index: Index of the tile (0-based). Defaults to 0.
387
+ length: Number of tiles to query (usually 1). Defaults to 1.
388
+ x: X coordinate of the rectangle (0-based). Defaults to 0.
389
+ y: Y coordinate of the rectangle (0-based). Defaults to 0.
390
+ width: Width of the rectangle in zones. Defaults to tile width.
390
391
 
391
392
  Returns:
392
- List of HSBK colors for the requested zones
393
+ List of HSBK colors for the requested zones. For tiles with ≤64 zones,
394
+ returns the actual zone count (e.g., 64 for 8x8, 16 for 4x4). For tiles
395
+ with >64 zones (e.g., 128 for 16x8 Ceiling), returns 64 (protocol limit).
393
396
 
394
397
  Example:
395
- >>> # Get colors from 8x8 tile (64 zones)
396
- >>> colors = await matrix.get64(tile_index=0, length=1, x=0, y=0, width=8)
398
+ >>> # Get all colors from first tile (no parameters needed)
399
+ >>> colors = await matrix.get64()
400
+ >>>
401
+ >>> # Get colors from specific region
402
+ >>> colors = await matrix.get64(y=4) # Start at row 4
397
403
  """
398
404
  # Validate parameters
399
405
  if x < 0:
400
406
  raise ValueError(f"x coordinate must be non-negative, got {x}")
401
407
  if y < 0:
402
408
  raise ValueError(f"y coordinate must be non-negative, got {y}")
403
- if width <= 0:
409
+ if width is not None and width <= 0:
404
410
  raise ValueError(f"width must be positive, got {width}")
405
411
 
412
+ if self._device_chain is None:
413
+ device_chain = await self.get_device_chain()
414
+ else:
415
+ device_chain = self._device_chain
416
+
417
+ if width is None:
418
+ width = device_chain[0].width
419
+
406
420
  _LOGGER.debug(
407
- "Getting 64 zones from tile %d (x=%d, y=%d, width=%d, fb=%d) for %s",
421
+ "Getting 64 zones from tile %d (x=%d, y=%d, width=%d) for %s",
408
422
  tile_index,
409
423
  x,
410
424
  y,
411
425
  width,
412
- fb_index,
413
426
  self.label or self.serial,
414
427
  )
415
428
 
@@ -417,12 +430,17 @@ class MatrixLight(Light):
417
430
  packets.Tile.Get64(
418
431
  tile_index=tile_index,
419
432
  length=length,
420
- rect=TileBufferRect(fb_index=fb_index, x=x, y=y, width=width),
433
+ rect=TileBufferRect(fb_index=0, x=x, y=y, width=width),
421
434
  )
422
435
  )
423
436
 
437
+ max_colors = device_chain[0].width * device_chain[0].height
438
+
424
439
  # Convert protocol colors to HSBK
425
- return [HSBK.from_protocol(proto_color) for proto_color in response.colors]
440
+ return [
441
+ HSBK.from_protocol(proto_color)
442
+ for proto_color in response.colors[:max_colors]
443
+ ]
426
444
 
427
445
  async def set64(
428
446
  self,
@@ -504,7 +522,11 @@ class MatrixLight(Light):
504
522
  )
505
523
 
506
524
  async def copy_frame_buffer(
507
- self, tile_index: int, source_fb: int = 1, target_fb: int = 0
525
+ self,
526
+ tile_index: int,
527
+ source_fb: int = 1,
528
+ target_fb: int = 0,
529
+ duration: float = 0.0,
508
530
  ) -> None:
509
531
  """Copy frame buffer (for tiles with >64 zones).
510
532
 
@@ -515,6 +537,7 @@ class MatrixLight(Light):
515
537
  tile_index: Index of the tile (0-based)
516
538
  source_fb: Source frame buffer index (usually 1)
517
539
  target_fb: Target frame buffer index (usually 0)
540
+ duration: time in seconds to transition if target_fb is 0
518
541
 
519
542
  Example:
520
543
  >>> # For 16x8 tile (128 zones):
@@ -541,7 +564,9 @@ class MatrixLight(Light):
541
564
  ... fb_index=1,
542
565
  ... )
543
566
  >>> # 3. Copy buffer 1 to buffer 0 (display)
544
- >>> await matrix.copy_frame_buffer(tile_index=0, source_fb=1, target_fb=0)
567
+ >>> await matrix.copy_frame_buffer(
568
+ ... tile_index=0, source_fb=1, target_fb=0, duration=2.0
569
+ ... )
545
570
  """
546
571
  _LOGGER.debug(
547
572
  "Copying frame buffer %d -> %d for tile %d on %s",
@@ -559,6 +584,7 @@ class MatrixLight(Light):
559
584
  raise ValueError(f"Invalid tile_index {tile_index}")
560
585
 
561
586
  tile = self._device_chain[tile_index]
587
+ duration_ms = round(duration * 1000 if duration else 0)
562
588
 
563
589
  await self.connection.send_packet(
564
590
  packets.Tile.CopyFrameBuffer(
@@ -572,7 +598,7 @@ class MatrixLight(Light):
572
598
  dst_y=0,
573
599
  width=tile.width,
574
600
  height=tile.height,
575
- duration=0,
601
+ duration=duration_ms,
576
602
  )
577
603
  )
578
604
 
@@ -723,7 +749,7 @@ class MatrixLight(Light):
723
749
  async def set_effect(
724
750
  self,
725
751
  effect_type: FirmwareEffect,
726
- speed: int = 3000,
752
+ speed: float = 3.0,
727
753
  duration: int = 0,
728
754
  palette: list[HSBK] | None = None,
729
755
  sky_type: TileEffectSkyType = TileEffectSkyType.SUNRISE,
@@ -734,7 +760,7 @@ class MatrixLight(Light):
734
760
 
735
761
  Args:
736
762
  effect_type: Type of effect (OFF, MORPH, FLAME, SKY)
737
- speed: Effect speed in milliseconds (default: 3000)
763
+ speed: Effect speed in seconds (default: 3)
738
764
  duration: Total effect duration in nanoseconds (0 for infinite)
739
765
  palette: Color palette for the effect (max 16 colors)
740
766
  sky_type: Sky effect type (SUNRISE, SUNSET, CLOUDS)
@@ -751,7 +777,7 @@ class MatrixLight(Light):
751
777
  ... ]
752
778
  >>> await matrix.set_effect(
753
779
  ... effect_type=FirmwareEffect.MORPH,
754
- ... speed=5000,
780
+ ... speed=5.0,
755
781
  ... palette=rainbow,
756
782
  ... )
757
783
  """
@@ -761,11 +787,12 @@ class MatrixLight(Light):
761
787
  speed,
762
788
  self.label or self.serial,
763
789
  )
790
+ speed_ms = round(speed * 1000) if speed else 3000
764
791
 
765
792
  # Create and validate MatrixEffect
766
793
  effect = MatrixEffect(
767
794
  effect_type=effect_type,
768
- speed=speed,
795
+ speed=speed_ms,
769
796
  duration=duration,
770
797
  palette=palette,
771
798
  sky_type=sky_type,
@@ -843,9 +870,23 @@ class MatrixLight(Light):
843
870
  # Create canvas and populate with theme colors
844
871
  canvas = Canvas()
845
872
  for tile in tiles:
846
- canvas.add_points_for_tile(None, theme)
847
- canvas.shuffle_points()
848
- canvas.blur_by_distance()
873
+ canvas.add_points_for_tile((int(tile.user_x), int(tile.user_y)), theme)
874
+ canvas.shuffle_points()
875
+ canvas.blur_by_distance()
876
+
877
+ # Create tile canvas and fill in gaps for smooth interpolation
878
+ tile_canvas = Canvas()
879
+ for tile in tiles:
880
+ tile_canvas.fill_in_points(
881
+ canvas,
882
+ int(tile.user_x),
883
+ int(tile.user_y),
884
+ tile.width,
885
+ tile.height,
886
+ )
887
+
888
+ # Final blur for smooth gradients
889
+ tile_canvas.blur()
849
890
 
850
891
  # Check if light is on
851
892
  is_on = await self.get_power()
@@ -853,7 +894,10 @@ class MatrixLight(Light):
853
894
  # Apply colors to each tile
854
895
  for tile in tiles:
855
896
  # Extract tile colors from canvas as 1D list
856
- colors = canvas.points_for_tile(None, width=tile.width, height=tile.height)
897
+ tile_coords = (int(tile.user_x), int(tile.user_y))
898
+ colors = tile_canvas.points_for_tile(
899
+ tile_coords, width=tile.width, height=tile.height
900
+ )
857
901
 
858
902
  # Apply with appropriate timing
859
903
  if power_on and not is_on:
@@ -467,13 +467,10 @@ class DeviceConnection:
467
467
  correlation_keys: list[tuple[int, int, str]] = []
468
468
 
469
469
  # Calculate per-attempt timeouts with exponential backoff
470
- # Start with a reasonable minimum (100ms) to avoid too-short initial timeouts
471
- min_attempt_timeout = 0.1 # 100ms minimum
472
- if max_retries > 0:
473
- attempt_timeout = max(min_attempt_timeout, timeout / (2 * max_retries))
474
- else:
475
- # Only one attempt total, use entire timeout
476
- attempt_timeout = timeout
470
+ # Use proper exponential backoff distribution: timeout / (2^(n+1) - 1)
471
+ # This ensures total of all attempt timeouts equals the overall timeout budget
472
+ total_weight = (2 ** (max_retries + 1)) - 1
473
+ base_timeout = timeout / total_weight
477
474
 
478
475
  # Idle timeout for multi-response protocols
479
476
  # Stop streaming if no responses for this long after first response
@@ -482,14 +479,17 @@ class DeviceConnection:
482
479
  last_error: Exception | None = None
483
480
  has_yielded = False
484
481
  overall_start = time.monotonic()
482
+ total_sleep_time = 0.0 # Track sleep time to exclude from timeout budget
485
483
 
486
484
  try:
487
485
  for attempt in range(max_retries + 1):
488
486
  # Calculate current attempt timeout with exponential backoff
489
- current_timeout = min(
490
- attempt_timeout * (2**attempt),
491
- timeout - (time.monotonic() - overall_start),
487
+ # Exclude sleep time from elapsed time to preserve timeout budget
488
+ elapsed_response_time = (
489
+ time.monotonic() - overall_start - total_sleep_time
492
490
  )
491
+ ideal_timeout = base_timeout * (2**attempt)
492
+ current_timeout = min(ideal_timeout, timeout - elapsed_response_time)
493
493
 
494
494
  # Check if we've exceeded overall timeout budget
495
495
  if current_timeout <= 0:
@@ -616,6 +616,9 @@ class DeviceConnection:
616
616
  # Sleep with jitter before retry
617
617
  sleep_time = self._calculate_retry_sleep_with_jitter(attempt)
618
618
  await asyncio.sleep(sleep_time)
619
+ total_sleep_time += (
620
+ sleep_time # Track sleep to exclude from timeout
621
+ )
619
622
  continue
620
623
  else:
621
624
  # All retries exhausted
@@ -673,9 +676,14 @@ class DeviceConnection:
673
676
  base_timeout = timeout / total_weight
674
677
 
675
678
  last_error: Exception | None = None
679
+ total_sleep_time = 0.0 # Track sleep time to exclude from timeout budget
680
+ overall_start = time.monotonic()
676
681
 
677
682
  for attempt in range(max_retries + 1):
678
- current_timeout = base_timeout * (2**attempt)
683
+ # Calculate timeout with budget remaining after excluding sleep time
684
+ elapsed_response_time = time.monotonic() - overall_start - total_sleep_time
685
+ ideal_timeout = base_timeout * (2**attempt)
686
+ current_timeout = min(ideal_timeout, timeout - elapsed_response_time)
679
687
  sequence = attempt
680
688
 
681
689
  # Correlation key: (source, sequence, serial)
@@ -737,6 +745,9 @@ class DeviceConnection:
737
745
  # Sleep with jitter before retry
738
746
  sleep_time = self._calculate_retry_sleep_with_jitter(attempt)
739
747
  await asyncio.sleep(sleep_time)
748
+ total_sleep_time += (
749
+ sleep_time # Track sleep to exclude from timeout
750
+ )
740
751
  continue
741
752
  else:
742
753
  break
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 4.3.2
3
+ Version: 4.3.4
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>
@@ -5,11 +5,11 @@ lifx/const.py,sha256=dW64lf_jwAD40GSd6hkFkrni5j-w2qkV3pl6YNdCxv4,3426
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=V7hW8sM_RwFgbR4Hv1ByR1JLhYq7Ft1X9pylQjCXYB8,777
8
- lifx/devices/base.py,sha256=bxnFRkYWmOfih9wDALKpiepOzlEabtuNS21tZhPVk6U,40765
8
+ lifx/devices/base.py,sha256=uD3hQe2kjycRZneSptON6psOhoEgPRHVelCoLgdHbFw,41482
9
9
  lifx/devices/hev.py,sha256=2zZNYm3TFrL755B4cRPNdYtcDLZEQwGl_22112WsSZc,9504
10
10
  lifx/devices/infrared.py,sha256=TrCgJyEioIPlFumMmcSmuGYmRsSGlQ5Rllg6_9Wtg4Y,4248
11
11
  lifx/devices/light.py,sha256=9rL24fa44Y7QrRBDSQuG6xpWsaPbQTTm4ExvrDnYWHo,27572
12
- lifx/devices/matrix.py,sha256=aN5e57R7uLA2YqcQi_mMjh1grrPiivMnT13M7_VDA0Q,30852
12
+ lifx/devices/matrix.py,sha256=zj_AE2C0WfSjMZw8X7Y6KkG1PUr0ViLC7zvVrVnWMPI,32504
13
13
  lifx/devices/multizone.py,sha256=c-lXcp8c1Mhs8me6smGkqQFrOOxdoGjWrOO5HnAVooY,27209
14
14
  lifx/effects/__init__.py,sha256=4DF31yp7RJic5JoltMlz5dCtF5KQobU6NOUtLUKkVKE,1509
15
15
  lifx/effects/base.py,sha256=YO0Hbg2VYHKPtfYnWxmrtzYoPGOi9BUXhn8HVFKv5IM,10283
@@ -20,7 +20,7 @@ lifx/effects/models.py,sha256=MS5D-cxD0Ar8XhqbqKAc9q2sk38IP1vPkYwd8V7jCr8,2446
20
20
  lifx/effects/pulse.py,sha256=t5eyjfFWG1xT-RXKghRqHYJ9CG_50tPu4jsDapJZ2mw,8721
21
21
  lifx/effects/state_manager.py,sha256=iDfYowiCN5IJqcR1s-pM0mQEJpe-RDsMcOOSMmtPVDE,8983
22
22
  lifx/network/__init__.py,sha256=uSyA8r8qISG7qXUHbX8uk9A2E8rvDADgCcf94QIZ9so,499
23
- lifx/network/connection.py,sha256=dgkTptyFf_3Cfd2_UCh_M75lVmIHTdGuU92nzqQcsdY,37358
23
+ lifx/network/connection.py,sha256=K5zzwYWKBvSBMqcP6a5Fp9JkVIiOLXJSAi1XMT4g-Go,38191
24
24
  lifx/network/discovery.py,sha256=FoFoZcw3dtJs1daESiZiNXytanKQsMTdF9PjOxEgHM0,23804
25
25
  lifx/network/message.py,sha256=jCLC9v0tbBi54g5CaHLFM_nP1Izu8kJmo2tt23HHBbA,2600
26
26
  lifx/network/transport.py,sha256=8QS0YV32rdP0EDiPEwuvZXbplRWL08pmjKybd87mkZ0,11070
@@ -40,7 +40,7 @@ lifx/theme/canvas.py,sha256=4h7lgN8iu_OdchObGDgbxTqQLCb-FRKC-M-YCWef_i4,8048
40
40
  lifx/theme/generators.py,sha256=L0X6_iApLx6XDboGlYunaVsl6nvUCqMfn23VQmRkyCk,6125
41
41
  lifx/theme/library.py,sha256=tKlKZNqJp8lRGDnilWyDm_Qr1vCRGGwuvWVS82anNpQ,21326
42
42
  lifx/theme/theme.py,sha256=qMEx_8E41C0Cc6f083XHiAXEglTv4YlXW0UFsG1rQKg,5521
43
- lifx_async-4.3.2.dist-info/METADATA,sha256=UUHjdQF5POJCyRlAARy0tL5egZKRkM-AAD9yA-R8pWo,2609
44
- lifx_async-4.3.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
45
- lifx_async-4.3.2.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
46
- lifx_async-4.3.2.dist-info/RECORD,,
43
+ lifx_async-4.3.4.dist-info/METADATA,sha256=MkrET2BAkIwK9D5rlzSmJyM9pp2DyHzBbSjHLvF27AM,2609
44
+ lifx_async-4.3.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
45
+ lifx_async-4.3.4.dist-info/licenses/LICENSE,sha256=eBz48GRA3gSiWn3rYZAz2Ewp35snnhV9cSqkVBq7g3k,1832
46
+ lifx_async-4.3.4.dist-info/RECORD,,