lifx-async 4.3.2__tar.gz → 4.3.4__tar.gz

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.
Files changed (145) hide show
  1. {lifx_async-4.3.2 → lifx_async-4.3.4}/PKG-INFO +1 -1
  2. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/changelog.md +19 -0
  3. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/11_matrix_basic.py +1 -3
  4. {lifx_async-4.3.2 → lifx_async-4.3.4}/pyproject.toml +1 -1
  5. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/devices/base.py +30 -6
  6. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/devices/matrix.py +75 -31
  7. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/network/connection.py +22 -11
  8. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/test_base.py +20 -9
  9. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/test_matrix.py +70 -109
  10. lifx_async-4.3.4/tests/test_network/test_concurrent_requests.py +395 -0
  11. {lifx_async-4.3.2 → lifx_async-4.3.4}/uv.lock +1 -1
  12. lifx_async-4.3.2/tests/test_network/test_concurrent_requests.py +0 -199
  13. {lifx_async-4.3.2 → lifx_async-4.3.4}/.claude/settings.json +0 -0
  14. {lifx_async-4.3.2 → lifx_async-4.3.4}/.github/dependabot.yml +0 -0
  15. {lifx_async-4.3.2 → lifx_async-4.3.4}/.github/labeler.yml +0 -0
  16. {lifx_async-4.3.2 → lifx_async-4.3.4}/.github/workflows/ci.yml +0 -0
  17. {lifx_async-4.3.2 → lifx_async-4.3.4}/.github/workflows/docs.yml +0 -0
  18. {lifx_async-4.3.2 → lifx_async-4.3.4}/.github/workflows/pr-automation.yml +0 -0
  19. {lifx_async-4.3.2 → lifx_async-4.3.4}/.gitignore +0 -0
  20. {lifx_async-4.3.2 → lifx_async-4.3.4}/.pre-commit-config.yaml +0 -0
  21. {lifx_async-4.3.2 → lifx_async-4.3.4}/CLAUDE.md +0 -0
  22. {lifx_async-4.3.2 → lifx_async-4.3.4}/LICENSE +0 -0
  23. {lifx_async-4.3.2 → lifx_async-4.3.4}/README.md +0 -0
  24. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/colors.md +0 -0
  25. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/devices.md +0 -0
  26. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/effects.md +0 -0
  27. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/exceptions.md +0 -0
  28. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/high-level.md +0 -0
  29. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/index.md +0 -0
  30. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/network.md +0 -0
  31. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/protocol.md +0 -0
  32. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/api/themes.md +0 -0
  33. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/architecture/effects-architecture.md +0 -0
  34. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/architecture/overview.md +0 -0
  35. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/faq.md +0 -0
  36. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/getting-started/effects.md +0 -0
  37. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/getting-started/installation.md +0 -0
  38. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/getting-started/quickstart.md +0 -0
  39. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/getting-started/themes.md +0 -0
  40. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/index.md +0 -0
  41. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/migration/effect-api-changes.md +0 -0
  42. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/stylesheets/extra.css +0 -0
  43. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/user-guide/advanced-usage.md +0 -0
  44. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/user-guide/effects-custom.md +0 -0
  45. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/user-guide/effects-troubleshooting.md +0 -0
  46. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/user-guide/protocol-deep-dive.md +0 -0
  47. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/user-guide/themes.md +0 -0
  48. {lifx_async-4.3.2 → lifx_async-4.3.4}/docs/user-guide/troubleshooting.md +0 -0
  49. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/01_simple_discovery.py +0 -0
  50. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/02_simple_control.py +0 -0
  51. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/03_waveforms.py +0 -0
  52. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/04_logging.py +0 -0
  53. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/06_pulse_effect.py +0 -0
  54. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/07_colorloop_effect.py +0 -0
  55. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/08_custom_effect.py +0 -0
  56. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/09_background_effect.py +0 -0
  57. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/10_find_specific_devices.py +0 -0
  58. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/12_matrix_effects.py +0 -0
  59. {lifx_async-4.3.2 → lifx_async-4.3.4}/examples/13_matrix_large.py +0 -0
  60. {lifx_async-4.3.2 → lifx_async-4.3.4}/mkdocs.yml +0 -0
  61. {lifx_async-4.3.2 → lifx_async-4.3.4}/renovate.json +0 -0
  62. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/__init__.py +0 -0
  63. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/api.py +0 -0
  64. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/color.py +0 -0
  65. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/const.py +0 -0
  66. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/devices/__init__.py +0 -0
  67. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/devices/hev.py +0 -0
  68. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/devices/infrared.py +0 -0
  69. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/devices/light.py +0 -0
  70. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/devices/multizone.py +0 -0
  71. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/effects/__init__.py +0 -0
  72. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/effects/base.py +0 -0
  73. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/effects/colorloop.py +0 -0
  74. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/effects/conductor.py +0 -0
  75. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/effects/const.py +0 -0
  76. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/effects/models.py +0 -0
  77. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/effects/pulse.py +0 -0
  78. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/effects/state_manager.py +0 -0
  79. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/exceptions.py +0 -0
  80. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/network/__init__.py +0 -0
  81. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/network/discovery.py +0 -0
  82. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/network/message.py +0 -0
  83. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/network/transport.py +0 -0
  84. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/products/__init__.py +0 -0
  85. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/products/generator.py +0 -0
  86. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/products/registry.py +0 -0
  87. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/protocol/__init__.py +0 -0
  88. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/protocol/base.py +0 -0
  89. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/protocol/generator.py +0 -0
  90. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/protocol/header.py +0 -0
  91. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/protocol/models.py +0 -0
  92. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/protocol/packets.py +0 -0
  93. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/protocol/protocol_types.py +0 -0
  94. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/protocol/serializer.py +0 -0
  95. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/py.typed +0 -0
  96. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/theme/__init__.py +0 -0
  97. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/theme/canvas.py +0 -0
  98. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/theme/generators.py +0 -0
  99. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/theme/library.py +0 -0
  100. {lifx_async-4.3.2 → lifx_async-4.3.4}/src/lifx/theme/theme.py +0 -0
  101. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/__init__.py +0 -0
  102. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/conftest.py +0 -0
  103. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_api/__init__.py +0 -0
  104. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_api/test_api_apply_theme.py +0 -0
  105. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_api/test_api_batch_errors.py +0 -0
  106. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_api/test_api_batch_operations.py +0 -0
  107. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_api/test_api_discovery.py +0 -0
  108. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_api/test_api_organization.py +0 -0
  109. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_color.py +0 -0
  110. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/__init__.py +0 -0
  111. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/conftest.py +0 -0
  112. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/test_hev.py +0 -0
  113. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/test_infrared.py +0 -0
  114. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/test_light.py +0 -0
  115. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/test_mac_address.py +0 -0
  116. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_devices/test_multizone.py +0 -0
  117. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_effects/__init__.py +0 -0
  118. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_effects/test_base.py +0 -0
  119. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_effects/test_capability_filtering.py +0 -0
  120. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_effects/test_colorloop.py +0 -0
  121. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_effects/test_integration.py +0 -0
  122. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_effects/test_models.py +0 -0
  123. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_effects/test_pulse.py +0 -0
  124. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_effects/test_state_manager.py +0 -0
  125. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_network/__init__.py +0 -0
  126. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_network/test_connection.py +0 -0
  127. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_network/test_discovery_devices.py +0 -0
  128. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_network/test_discovery_errors.py +0 -0
  129. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_network/test_message.py +0 -0
  130. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_network/test_message_advanced.py +0 -0
  131. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_network/test_transport.py +0 -0
  132. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_products/test_product_generator.py +0 -0
  133. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_products/test_registry.py +0 -0
  134. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_protocol/test_generated.py +0 -0
  135. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_protocol/test_header.py +0 -0
  136. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_protocol/test_protocol_generator.py +0 -0
  137. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_protocol/test_serializer.py +0 -0
  138. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_theme/__init__.py +0 -0
  139. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_theme/conftest.py +0 -0
  140. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_theme/test_apply_theme.py +0 -0
  141. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_theme/test_canvas.py +0 -0
  142. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_theme/test_generators.py +0 -0
  143. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_theme/test_library.py +0 -0
  144. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_theme/test_theme.py +0 -0
  145. {lifx_async-4.3.2 → lifx_async-4.3.4}/tests/test_utils.py +0 -0
@@ -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>
@@ -2,6 +2,25 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v4.3.4 (2025-11-22)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **network**: Exclude retry sleep time from timeout budget
10
+ ([`312d7a7`](https://github.com/Djelibeybi/lifx-async/commit/312d7a7e2561de7d2bbf142c8a521daca31651bb))
11
+
12
+
13
+ ## v4.3.3 (2025-11-22)
14
+
15
+ ### Bug Fixes
16
+
17
+ - Give MatrixLight.get64() some default parameters
18
+ ([`a69a49c`](https://github.com/Djelibeybi/lifx-async/commit/a69a49c93488c79c8c3be58a9304fd01b4b12231))
19
+
20
+ - **themes**: Apply theme colors to all zones via proper canvas interpolation
21
+ ([`f1628c4`](https://github.com/Djelibeybi/lifx-async/commit/f1628c4a071d257d7db79a7945d1516c783d8d52))
22
+
23
+
5
24
  ## v4.3.2 (2025-11-22)
6
25
 
7
26
  ### Bug Fixes
@@ -35,9 +35,7 @@ async def main(ip: str):
35
35
 
36
36
  # Get current colors from first tile
37
37
  print("Getting current colors from tile 0...")
38
- tile_colors = await matrix.get64(
39
- tile_index=0, length=1, x=0, y=0, width=device_chain[0].width
40
- )
38
+ tile_colors = await matrix.get64()
41
39
  print(f"Retrieved {len(tile_colors)} colors\n")
42
40
 
43
41
  if power == 0:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-async"
3
- version = "4.3.2"
3
+ version = "4.3.4"
4
4
  description = "A modern, type-safe, async Python library for controlling LIFX lights"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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:
@@ -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
@@ -527,7 +527,7 @@ class TestLocationAndGroupManagement:
527
527
  mock_discovered_conn.request = AsyncMock(return_value=mock_state_location)
528
528
 
529
529
  # Create async generator mock for discover_devices
530
- async def mock_discover_gen(timeout: float = 5.0):
530
+ async def mock_discover_gen(timeout: float = 5.0, **kwargs):
531
531
  for disc in discovered_devices:
532
532
  yield disc
533
533
 
@@ -537,8 +537,10 @@ class TestLocationAndGroupManagement:
537
537
  ),
538
538
  patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
539
539
  ):
540
- # First call: discovered device, second call: this device
541
- mock_conn_class.side_effect = [mock_discovered_conn, device.connection]
540
+ # Only one DeviceConnection created for discovered device
541
+ mock_conn_class.return_value = mock_discovered_conn
542
+ # Add async close method to mock
543
+ mock_discovered_conn.close = AsyncMock()
542
544
 
543
545
  await device.set_location(label)
544
546
 
@@ -581,7 +583,7 @@ class TestLocationAndGroupManagement:
581
583
  mock_discovered_conn.request = AsyncMock(return_value=mock_state_location)
582
584
 
583
585
  # Create async generator mock for discover_devices
584
- async def mock_discover_gen(timeout: float = 5.0):
586
+ async def mock_discover_gen(timeout: float = 5.0, **kwargs):
585
587
  for disc in discovered_devices:
586
588
  yield disc
587
589
 
@@ -591,7 +593,10 @@ class TestLocationAndGroupManagement:
591
593
  ),
592
594
  patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
593
595
  ):
594
- mock_conn_class.side_effect = [mock_discovered_conn, device.connection]
596
+ # Only one DeviceConnection created for discovered device
597
+ mock_conn_class.return_value = mock_discovered_conn
598
+ # Add async close method to mock
599
+ mock_discovered_conn.close = AsyncMock()
595
600
 
596
601
  await device.set_location(label)
597
602
 
@@ -629,7 +634,7 @@ class TestLocationAndGroupManagement:
629
634
  mock_discovered_conn.request = AsyncMock(return_value=mock_state_group)
630
635
 
631
636
  # Create async generator mock for discover_devices
632
- async def mock_discover_gen(timeout: float = 5.0):
637
+ async def mock_discover_gen(timeout: float = 5.0, **kwargs):
633
638
  for disc in discovered_devices:
634
639
  yield disc
635
640
 
@@ -639,7 +644,10 @@ class TestLocationAndGroupManagement:
639
644
  ),
640
645
  patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
641
646
  ):
642
- mock_conn_class.side_effect = [mock_discovered_conn, device.connection]
647
+ # Only one DeviceConnection created for discovered device
648
+ mock_conn_class.return_value = mock_discovered_conn
649
+ # Add async close method to mock
650
+ mock_discovered_conn.close = AsyncMock()
643
651
 
644
652
  await device.set_group(label)
645
653
 
@@ -682,7 +690,7 @@ class TestLocationAndGroupManagement:
682
690
  mock_discovered_conn.request = AsyncMock(return_value=mock_state_group)
683
691
 
684
692
  # Create async generator mock for discover_devices
685
- async def mock_discover_gen(timeout: float = 5.0):
693
+ async def mock_discover_gen(timeout: float = 5.0, **kwargs):
686
694
  for disc in discovered_devices:
687
695
  yield disc
688
696
 
@@ -692,7 +700,10 @@ class TestLocationAndGroupManagement:
692
700
  ),
693
701
  patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
694
702
  ):
695
- mock_conn_class.side_effect = [mock_discovered_conn, device.connection]
703
+ # Only one DeviceConnection created for discovered device
704
+ mock_conn_class.return_value = mock_discovered_conn
705
+ # Add async close method to mock
706
+ mock_discovered_conn.close = AsyncMock()
696
707
 
697
708
  await device.set_group(label)
698
709