lifx-async 4.3.3__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.3 → lifx_async-4.3.4}/PKG-INFO +1 -1
  2. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/changelog.md +8 -0
  3. {lifx_async-4.3.3 → lifx_async-4.3.4}/pyproject.toml +1 -1
  4. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/base.py +30 -6
  5. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/connection.py +22 -11
  6. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_base.py +20 -9
  7. lifx_async-4.3.4/tests/test_network/test_concurrent_requests.py +395 -0
  8. {lifx_async-4.3.3 → lifx_async-4.3.4}/uv.lock +1 -1
  9. lifx_async-4.3.3/tests/test_network/test_concurrent_requests.py +0 -199
  10. {lifx_async-4.3.3 → lifx_async-4.3.4}/.claude/settings.json +0 -0
  11. {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/dependabot.yml +0 -0
  12. {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/labeler.yml +0 -0
  13. {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/workflows/ci.yml +0 -0
  14. {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/workflows/docs.yml +0 -0
  15. {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/workflows/pr-automation.yml +0 -0
  16. {lifx_async-4.3.3 → lifx_async-4.3.4}/.gitignore +0 -0
  17. {lifx_async-4.3.3 → lifx_async-4.3.4}/.pre-commit-config.yaml +0 -0
  18. {lifx_async-4.3.3 → lifx_async-4.3.4}/CLAUDE.md +0 -0
  19. {lifx_async-4.3.3 → lifx_async-4.3.4}/LICENSE +0 -0
  20. {lifx_async-4.3.3 → lifx_async-4.3.4}/README.md +0 -0
  21. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/colors.md +0 -0
  22. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/devices.md +0 -0
  23. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/effects.md +0 -0
  24. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/exceptions.md +0 -0
  25. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/high-level.md +0 -0
  26. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/index.md +0 -0
  27. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/network.md +0 -0
  28. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/protocol.md +0 -0
  29. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/themes.md +0 -0
  30. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/architecture/effects-architecture.md +0 -0
  31. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/architecture/overview.md +0 -0
  32. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/faq.md +0 -0
  33. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/getting-started/effects.md +0 -0
  34. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/getting-started/installation.md +0 -0
  35. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/getting-started/quickstart.md +0 -0
  36. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/getting-started/themes.md +0 -0
  37. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/index.md +0 -0
  38. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/migration/effect-api-changes.md +0 -0
  39. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/stylesheets/extra.css +0 -0
  40. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/advanced-usage.md +0 -0
  41. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/effects-custom.md +0 -0
  42. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/effects-troubleshooting.md +0 -0
  43. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/protocol-deep-dive.md +0 -0
  44. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/themes.md +0 -0
  45. {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/troubleshooting.md +0 -0
  46. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/01_simple_discovery.py +0 -0
  47. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/02_simple_control.py +0 -0
  48. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/03_waveforms.py +0 -0
  49. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/04_logging.py +0 -0
  50. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/06_pulse_effect.py +0 -0
  51. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/07_colorloop_effect.py +0 -0
  52. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/08_custom_effect.py +0 -0
  53. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/09_background_effect.py +0 -0
  54. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/10_find_specific_devices.py +0 -0
  55. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/11_matrix_basic.py +0 -0
  56. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/12_matrix_effects.py +0 -0
  57. {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/13_matrix_large.py +0 -0
  58. {lifx_async-4.3.3 → lifx_async-4.3.4}/mkdocs.yml +0 -0
  59. {lifx_async-4.3.3 → lifx_async-4.3.4}/renovate.json +0 -0
  60. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/__init__.py +0 -0
  61. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/api.py +0 -0
  62. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/color.py +0 -0
  63. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/const.py +0 -0
  64. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/__init__.py +0 -0
  65. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/hev.py +0 -0
  66. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/infrared.py +0 -0
  67. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/light.py +0 -0
  68. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/matrix.py +0 -0
  69. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/multizone.py +0 -0
  70. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/__init__.py +0 -0
  71. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/base.py +0 -0
  72. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/colorloop.py +0 -0
  73. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/conductor.py +0 -0
  74. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/const.py +0 -0
  75. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/models.py +0 -0
  76. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/pulse.py +0 -0
  77. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/state_manager.py +0 -0
  78. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/exceptions.py +0 -0
  79. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/__init__.py +0 -0
  80. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/discovery.py +0 -0
  81. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/message.py +0 -0
  82. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/transport.py +0 -0
  83. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/products/__init__.py +0 -0
  84. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/products/generator.py +0 -0
  85. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/products/registry.py +0 -0
  86. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/__init__.py +0 -0
  87. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/base.py +0 -0
  88. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/generator.py +0 -0
  89. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/header.py +0 -0
  90. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/models.py +0 -0
  91. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/packets.py +0 -0
  92. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/protocol_types.py +0 -0
  93. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/serializer.py +0 -0
  94. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/py.typed +0 -0
  95. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/__init__.py +0 -0
  96. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/canvas.py +0 -0
  97. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/generators.py +0 -0
  98. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/library.py +0 -0
  99. {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/theme.py +0 -0
  100. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/__init__.py +0 -0
  101. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/conftest.py +0 -0
  102. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/__init__.py +0 -0
  103. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_apply_theme.py +0 -0
  104. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_batch_errors.py +0 -0
  105. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_batch_operations.py +0 -0
  106. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_discovery.py +0 -0
  107. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_organization.py +0 -0
  108. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_color.py +0 -0
  109. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/__init__.py +0 -0
  110. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/conftest.py +0 -0
  111. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_hev.py +0 -0
  112. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_infrared.py +0 -0
  113. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_light.py +0 -0
  114. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_mac_address.py +0 -0
  115. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_matrix.py +0 -0
  116. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_multizone.py +0 -0
  117. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/__init__.py +0 -0
  118. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_base.py +0 -0
  119. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_capability_filtering.py +0 -0
  120. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_colorloop.py +0 -0
  121. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_integration.py +0 -0
  122. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_models.py +0 -0
  123. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_pulse.py +0 -0
  124. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_state_manager.py +0 -0
  125. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/__init__.py +0 -0
  126. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_connection.py +0 -0
  127. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_discovery_devices.py +0 -0
  128. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_discovery_errors.py +0 -0
  129. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_message.py +0 -0
  130. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_message_advanced.py +0 -0
  131. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_transport.py +0 -0
  132. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_products/test_product_generator.py +0 -0
  133. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_products/test_registry.py +0 -0
  134. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_protocol/test_generated.py +0 -0
  135. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_protocol/test_header.py +0 -0
  136. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_protocol/test_protocol_generator.py +0 -0
  137. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_protocol/test_serializer.py +0 -0
  138. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/__init__.py +0 -0
  139. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/conftest.py +0 -0
  140. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_apply_theme.py +0 -0
  141. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_canvas.py +0 -0
  142. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_generators.py +0 -0
  143. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_library.py +0 -0
  144. {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_theme.py +0 -0
  145. {lifx_async-4.3.3 → 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.3
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,14 @@
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
+
5
13
  ## v4.3.3 (2025-11-22)
6
14
 
7
15
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-async"
3
- version = "4.3.3"
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:
@@ -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
 
@@ -0,0 +1,395 @@
1
+ """Tests for concurrent request handling with DeviceConnection.
2
+
3
+ This module tests concurrent request/response handling through the
4
+ user-facing DeviceConnection API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+ import pytest
12
+
13
+ from lifx.exceptions import LifxTimeoutError
14
+ from lifx.protocol.packets import Device
15
+
16
+
17
+ class TestConcurrentRequests:
18
+ """Test concurrent request/response handling with DeviceConnection."""
19
+
20
+ async def test_timeout_behavior(self):
21
+ """Test that timeout raises LifxTimeoutError with no server response."""
22
+ from lifx.network.connection import DeviceConnection
23
+
24
+ conn = DeviceConnection(
25
+ serial="d073d5000001", ip="192.168.1.100", timeout=0.1, max_retries=0
26
+ )
27
+
28
+ # Request should timeout when no server is available
29
+ with pytest.raises(LifxTimeoutError):
30
+ await conn.request(Device.GetPower(), timeout=0.1)
31
+
32
+
33
+ class TestErrorHandling:
34
+ """Test error handling in concurrent scenarios using DeviceConnection."""
35
+
36
+ async def test_timeout_when_server_drops_packets(
37
+ self, emulator_server_with_scenarios
38
+ ):
39
+ """Test handling timeout when server drops packets (simulating no response)."""
40
+ # Create a scenario that drops Device.GetPower packets (pkt_type 20)
41
+ server, _device = await emulator_server_with_scenarios(
42
+ device_type="color",
43
+ serial="d073d5000001",
44
+ scenarios={
45
+ "drop_packets": {
46
+ "20": 1.0 # Drop 100% of GetPower responses (pkt_type 20)
47
+ }
48
+ },
49
+ )
50
+
51
+ from lifx.network.connection import DeviceConnection
52
+
53
+ conn = DeviceConnection(
54
+ serial="d073d5000001",
55
+ ip="127.0.0.1",
56
+ port=server.port,
57
+ timeout=0.5,
58
+ max_retries=0, # No retries for faster test
59
+ )
60
+
61
+ # This should timeout since server drops all GetPower packets
62
+ with pytest.raises(LifxTimeoutError):
63
+ await conn.request(Device.GetPower(), timeout=0.5)
64
+
65
+ async def test_concurrent_requests_with_one_timing_out(
66
+ self, emulator_server_with_scenarios
67
+ ):
68
+ """Test timeout isolation between concurrent requests."""
69
+ # Create a scenario that drops ONLY GetPower packets
70
+ server, _device = await emulator_server_with_scenarios(
71
+ device_type="color",
72
+ serial="d073d5000001",
73
+ scenarios={
74
+ "drop_packets": {
75
+ "20": 1.0 # Drop 100% of GetPower responses (pkt_type 20)
76
+ }
77
+ },
78
+ )
79
+
80
+ from lifx.network.connection import DeviceConnection
81
+
82
+ conn = DeviceConnection(
83
+ serial="d073d5000001",
84
+ ip="127.0.0.1",
85
+ port=server.port,
86
+ timeout=1.0,
87
+ max_retries=2,
88
+ )
89
+
90
+ # Create multiple concurrent requests where one will timeout
91
+ async def get_power():
92
+ """This will timeout."""
93
+ try:
94
+ await conn.request(Device.GetPower(), timeout=0.3)
95
+ return "power_success"
96
+ except LifxTimeoutError:
97
+ return "power_timeout"
98
+
99
+ async def get_label():
100
+ """This should succeed."""
101
+ try:
102
+ await conn.request(Device.GetLabel(), timeout=1.0)
103
+ return "label_success"
104
+ except LifxTimeoutError:
105
+ return "label_timeout"
106
+
107
+ # Run both concurrently
108
+ results = await asyncio.gather(get_power(), get_label())
109
+
110
+ # Power request should timeout, label should succeed
111
+ assert results[0] == "power_timeout"
112
+ assert results[1] == "label_success"
113
+
114
+
115
+ class TestAsyncGeneratorRequests:
116
+ """Test async generator-based request streaming."""
117
+
118
+ async def test_request_stream_single_response(self, emulator_server_with_scenarios):
119
+ """Test request_stream with single response exits immediately after break."""
120
+ server, _device = await emulator_server_with_scenarios(
121
+ device_type="color",
122
+ serial="d073d5000001",
123
+ scenarios={},
124
+ )
125
+
126
+ from lifx.network.connection import DeviceConnection
127
+
128
+ conn = DeviceConnection(
129
+ serial="d073d5000001",
130
+ ip="127.0.0.1",
131
+ port=server.port,
132
+ timeout=2.0,
133
+ max_retries=2,
134
+ )
135
+
136
+ # Stream should yield single response
137
+ received = []
138
+ async for response in conn.request_stream(Device.GetLabel()):
139
+ received.append(response)
140
+ break # Exit immediately after first response
141
+
142
+ assert len(received) == 1
143
+ assert hasattr(received[0], "label")
144
+
145
+ async def test_request_stream_convenience_wrapper(
146
+ self, emulator_server_with_scenarios
147
+ ):
148
+ """Test that request() convenience wrapper works correctly."""
149
+ server, _device = await emulator_server_with_scenarios(
150
+ device_type="color",
151
+ serial="d073d5000001",
152
+ scenarios={},
153
+ )
154
+
155
+ from lifx.network.connection import DeviceConnection
156
+
157
+ conn = DeviceConnection(
158
+ serial="d073d5000001",
159
+ ip="127.0.0.1",
160
+ port=server.port,
161
+ timeout=2.0,
162
+ max_retries=2,
163
+ )
164
+
165
+ # request() should return single response directly
166
+ response = await conn.request(Device.GetLabel())
167
+ assert hasattr(response, "label")
168
+
169
+ async def test_early_exit_no_resource_leak(self, emulator_server_with_scenarios):
170
+ """Test that breaking early doesn't leak resources."""
171
+ server, _device = await emulator_server_with_scenarios(
172
+ device_type="color",
173
+ serial="d073d5000001",
174
+ scenarios={},
175
+ )
176
+
177
+ from lifx.network.connection import DeviceConnection
178
+
179
+ conn = DeviceConnection(
180
+ serial="d073d5000001",
181
+ ip="127.0.0.1",
182
+ port=server.port,
183
+ timeout=2.0,
184
+ max_retries=2,
185
+ )
186
+
187
+ try:
188
+ # Stream and break early
189
+ async for _response in conn.request_stream(Device.GetLabel()):
190
+ break
191
+
192
+ # Verify connection is still functional
193
+ assert conn.is_open
194
+
195
+ # Make another request to verify no leak
196
+ response = await conn.request(Device.GetPower())
197
+ assert hasattr(response, "level")
198
+ finally:
199
+ await conn.close()
200
+
201
+
202
+ class TestRetryTimeoutBudget:
203
+ """Test that retry sleep time doesn't consume the timeout budget.
204
+
205
+ This test class verifies the fix for the issue where retry sleep time
206
+ was being counted against the overall timeout budget, causing later
207
+ retry attempts to have insufficient time to wait for responses.
208
+ """
209
+
210
+ async def test_retry_sleep_excluded_from_timeout_budget(
211
+ self, emulator_server_with_scenarios
212
+ ):
213
+ """Test that retry sleep time is excluded from timeout budget.
214
+
215
+ This test verifies that when retries occur with exponential backoff sleep,
216
+ the sleep time doesn't consume the overall timeout budget. Each retry
217
+ attempt should get a fair timeout window.
218
+
219
+ Without the fix, this would fail because later attempts would have
220
+ very short timeouts (e.g., 0.613s on attempt 4) due to accumulated sleep time.
221
+ """
222
+ import time
223
+
224
+ # Create a scenario that drops all packets to force retries
225
+ server, _device = await emulator_server_with_scenarios(
226
+ device_type="color",
227
+ serial="d073d5000001",
228
+ scenarios={
229
+ "drop_packets": {
230
+ "20": 1.0 # Drop 100% of GetPower responses (pkt_type 20)
231
+ }
232
+ },
233
+ )
234
+
235
+ from lifx.network.connection import DeviceConnection
236
+
237
+ # Set up connection with specific timeout and retries
238
+ timeout = 2.0 # 2 second total timeout budget
239
+ max_retries = 3 # 4 total attempts (0, 1, 2, 3)
240
+
241
+ conn = DeviceConnection(
242
+ serial="d073d5000001",
243
+ ip="127.0.0.1",
244
+ port=server.port,
245
+ timeout=timeout,
246
+ max_retries=max_retries,
247
+ )
248
+
249
+ # Calculate expected timeout distribution with exponential backoff
250
+ # total_weight = (2^(n+1) - 1) = (2^4 - 1) = 15
251
+ # base_timeout = 2.0 / 15 = 0.133s
252
+ # Attempt 0: 0.133 * 2^0 = 0.133s
253
+ # Attempt 1: 0.133 * 2^1 = 0.266s
254
+ # Attempt 2: 0.133 * 2^2 = 0.533s
255
+ # Attempt 3: 0.133 * 2^3 = 1.066s
256
+ # Total: 0.133 + 0.266 + 0.533 + 1.066 = ~2.0s
257
+
258
+ start_time = time.monotonic()
259
+
260
+ # This should timeout after all retries are exhausted
261
+ with pytest.raises(LifxTimeoutError) as exc_info:
262
+ await conn.request(Device.GetPower(), timeout=timeout)
263
+
264
+ elapsed = time.monotonic() - start_time
265
+
266
+ # Verify the timeout message
267
+ assert "after 4 attempts" in str(exc_info.value)
268
+
269
+ # The total elapsed time should be:
270
+ # - Timeout budget (2.0s)
271
+ # - Plus sleep time between retries (3 sleeps with exponential backoff)
272
+ # Sleep 0: random(0, 0.1 * 2^0) = random(0, 0.1)
273
+ # Sleep 1: random(0, 0.1 * 2^1) = random(0, 0.2)
274
+ # Sleep 2: random(0, 0.1 * 2^2) = random(0, 0.4)
275
+ # Max total sleep: 0.1 + 0.2 + 0.4 = 0.7s
276
+ # Total expected: 2.0 + 0.7 = 2.7s maximum
277
+
278
+ # Allow some tolerance for timing variations
279
+ assert elapsed >= timeout, "Should use at least the timeout budget"
280
+ assert elapsed < timeout + 1.0, (
281
+ f"Elapsed {elapsed}s should not exceed timeout + max_sleep (3.0s)"
282
+ )
283
+
284
+ # Key assertion: If sleep was counted against timeout budget,
285
+ # the elapsed time would be close to just the timeout (2.0s)
286
+ # because later attempts would fail immediately.
287
+ # With the fix, we should see elapsed > timeout + some sleep time.
288
+ assert elapsed > timeout + 0.1, (
289
+ "Sleep time should be added on top of timeout budget"
290
+ )
291
+
292
+ async def test_retry_timeout_calculation_consistency(
293
+ self, emulator_server_with_scenarios
294
+ ):
295
+ """Test that timeout calculation is consistent between GET and SET requests.
296
+
297
+ Both _request_stream_impl (GET) and _request_ack_stream_impl (SET)
298
+ should use the same timeout calculation formula.
299
+ """
300
+ import time
301
+
302
+ # Create a scenario that drops packets for both GET and SET
303
+ server, _device = await emulator_server_with_scenarios(
304
+ device_type="color",
305
+ serial="d073d5000001",
306
+ scenarios={
307
+ "drop_packets": {
308
+ "20": 1.0, # Drop GetPower (GET request)
309
+ "21": 1.0, # Drop SetPower (SET request)
310
+ }
311
+ },
312
+ )
313
+
314
+ from lifx.network.connection import DeviceConnection
315
+
316
+ timeout = 1.5
317
+ max_retries = 2 # 3 total attempts
318
+
319
+ conn = DeviceConnection(
320
+ serial="d073d5000001",
321
+ ip="127.0.0.1",
322
+ port=server.port,
323
+ timeout=timeout,
324
+ max_retries=max_retries,
325
+ )
326
+
327
+ # Test GET request (uses _request_stream_impl)
328
+ start_get = time.monotonic()
329
+ with pytest.raises(LifxTimeoutError):
330
+ await conn.request(Device.GetPower(), timeout=timeout)
331
+ elapsed_get = time.monotonic() - start_get
332
+
333
+ # Test SET request (uses _request_ack_stream_impl)
334
+ start_set = time.monotonic()
335
+ with pytest.raises(LifxTimeoutError):
336
+ await conn.request(Device.SetPower(level=65535), timeout=timeout)
337
+ elapsed_set = time.monotonic() - start_set
338
+
339
+ # Both should take approximately the same time (within tolerance)
340
+ # since they use the same timeout calculation and retry logic
341
+ time_diff = abs(elapsed_get - elapsed_set)
342
+ assert time_diff < 0.5, (
343
+ f"GET and SET timeout behavior should be consistent (diff: {time_diff}s)"
344
+ )
345
+
346
+ # Both should respect the timeout budget
347
+ assert elapsed_get >= timeout
348
+ assert elapsed_set >= timeout
349
+
350
+ async def test_retry_all_attempts_get_fair_timeout(
351
+ self, emulator_server_with_scenarios
352
+ ):
353
+ """Test that all retry attempts get adequate timeout windows.
354
+
355
+ This verifies that later retry attempts aren't starved of timeout
356
+ due to accumulated sleep time from earlier attempts.
357
+ """
358
+ # Create a scenario that drops packets to force retries
359
+ server, _device = await emulator_server_with_scenarios(
360
+ device_type="color",
361
+ serial="d073d5000001",
362
+ scenarios={
363
+ "drop_packets": {
364
+ "20": 1.0 # Drop all GetPower responses
365
+ }
366
+ },
367
+ )
368
+
369
+ from lifx.network.connection import DeviceConnection
370
+
371
+ # Use settings similar to real-world usage
372
+ timeout = 8.0 # Default timeout
373
+ max_retries = 4 # 5 total attempts (like in the error log)
374
+
375
+ conn = DeviceConnection(
376
+ serial="d073d5000001",
377
+ ip="127.0.0.1",
378
+ port=server.port,
379
+ timeout=timeout,
380
+ max_retries=max_retries,
381
+ )
382
+
383
+ # This should timeout after all retries
384
+ with pytest.raises(LifxTimeoutError) as exc_info:
385
+ await conn.request(Device.GetPower(), timeout=timeout)
386
+
387
+ # Verify all attempts were made
388
+ assert "after 5 attempts" in str(exc_info.value)
389
+
390
+ # The error message should NOT show a very short timeout on later attempts
391
+ # (like "No response within 0.613s" which would indicate the bug)
392
+ error_msg = str(exc_info.value)
393
+
394
+ # The error should be about exhausting all attempts, not a premature timeout
395
+ assert "No response from" in error_msg
@@ -432,7 +432,7 @@ wheels = [
432
432
 
433
433
  [[package]]
434
434
  name = "lifx-async"
435
- version = "4.3.3"
435
+ version = "4.3.4"
436
436
  source = { editable = "." }
437
437
 
438
438
  [package.dev-dependencies]