lifx-async 4.7.2__tar.gz → 4.7.3__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 (156) hide show
  1. {lifx_async-4.7.2 → lifx_async-4.7.3}/PKG-INFO +1 -1
  2. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/changelog.md +8 -0
  3. {lifx_async-4.7.2 → lifx_async-4.7.3}/pyproject.toml +1 -1
  4. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/ceiling.py +59 -0
  5. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_ceiling.py +236 -1
  6. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_integration.py +56 -9
  7. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_connection.py +4 -2
  8. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_canvas.py +4 -2
  9. {lifx_async-4.7.2 → lifx_async-4.7.3}/uv.lock +1 -1
  10. {lifx_async-4.7.2 → lifx_async-4.7.3}/.claude/settings.json +0 -0
  11. {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/dependabot.yml +0 -0
  12. {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/labeler.yml +0 -0
  13. {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/workflows/ci.yml +0 -0
  14. {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/workflows/docs.yml +0 -0
  15. {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/workflows/pr-automation.yml +0 -0
  16. {lifx_async-4.7.2 → lifx_async-4.7.3}/.gitignore +0 -0
  17. {lifx_async-4.7.2 → lifx_async-4.7.3}/.pre-commit-config.yaml +0 -0
  18. {lifx_async-4.7.2 → lifx_async-4.7.3}/CLAUDE.md +0 -0
  19. {lifx_async-4.7.2 → lifx_async-4.7.3}/LICENSE +0 -0
  20. {lifx_async-4.7.2 → lifx_async-4.7.3}/README.md +0 -0
  21. {lifx_async-4.7.2 → lifx_async-4.7.3}/context7.json +0 -0
  22. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/colors.md +0 -0
  23. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/devices.md +0 -0
  24. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/effects.md +0 -0
  25. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/exceptions.md +0 -0
  26. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/high-level.md +0 -0
  27. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/index.md +0 -0
  28. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/network.md +0 -0
  29. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/protocol.md +0 -0
  30. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/themes.md +0 -0
  31. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/architecture/effects-architecture.md +0 -0
  32. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/architecture/overview.md +0 -0
  33. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/faq.md +0 -0
  34. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/getting-started/effects.md +0 -0
  35. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/getting-started/installation.md +0 -0
  36. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/getting-started/quickstart.md +0 -0
  37. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/getting-started/themes.md +0 -0
  38. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/index.md +0 -0
  39. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/migration/effect-api-changes.md +0 -0
  40. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/stylesheets/extra.css +0 -0
  41. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/advanced-usage.md +0 -0
  42. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/ceiling-lights.md +0 -0
  43. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/effects-custom.md +0 -0
  44. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/effects-troubleshooting.md +0 -0
  45. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/protocol-deep-dive.md +0 -0
  46. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/themes.md +0 -0
  47. {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/troubleshooting.md +0 -0
  48. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/01_simple_discovery.py +0 -0
  49. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/02_simple_control.py +0 -0
  50. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/03_waveforms.py +0 -0
  51. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/04_logging.py +0 -0
  52. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/06_pulse_effect.py +0 -0
  53. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/07_colorloop_effect.py +0 -0
  54. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/08_custom_effect.py +0 -0
  55. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/09_background_effect.py +0 -0
  56. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/10_find_specific_devices.py +0 -0
  57. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/11_matrix_basic.py +0 -0
  58. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/12_matrix_effects.py +0 -0
  59. {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/13_matrix_large.py +0 -0
  60. {lifx_async-4.7.2 → lifx_async-4.7.3}/mkdocs.yml +0 -0
  61. {lifx_async-4.7.2 → lifx_async-4.7.3}/renovate.json +0 -0
  62. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/__init__.py +0 -0
  63. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/api.py +0 -0
  64. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/color.py +0 -0
  65. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/const.py +0 -0
  66. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/__init__.py +0 -0
  67. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/base.py +0 -0
  68. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/hev.py +0 -0
  69. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/infrared.py +0 -0
  70. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/light.py +0 -0
  71. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/matrix.py +0 -0
  72. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/multizone.py +0 -0
  73. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/__init__.py +0 -0
  74. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/base.py +0 -0
  75. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/colorloop.py +0 -0
  76. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/conductor.py +0 -0
  77. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/const.py +0 -0
  78. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/models.py +0 -0
  79. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/pulse.py +0 -0
  80. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/state_manager.py +0 -0
  81. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/exceptions.py +0 -0
  82. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/__init__.py +0 -0
  83. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/connection.py +0 -0
  84. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/discovery.py +0 -0
  85. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/message.py +0 -0
  86. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/transport.py +0 -0
  87. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/products/__init__.py +0 -0
  88. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/products/generator.py +0 -0
  89. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/products/quirks.py +0 -0
  90. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/products/registry.py +0 -0
  91. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/__init__.py +0 -0
  92. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/base.py +0 -0
  93. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/generator.py +0 -0
  94. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/header.py +0 -0
  95. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/models.py +0 -0
  96. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/packets.py +0 -0
  97. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/protocol_types.py +0 -0
  98. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/serializer.py +0 -0
  99. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/py.typed +0 -0
  100. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/__init__.py +0 -0
  101. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/canvas.py +0 -0
  102. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/generators.py +0 -0
  103. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/library.py +0 -0
  104. {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/theme.py +0 -0
  105. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/__init__.py +0 -0
  106. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/conftest.py +0 -0
  107. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/__init__.py +0 -0
  108. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_apply_theme.py +0 -0
  109. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_batch_errors.py +0 -0
  110. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_batch_operations.py +0 -0
  111. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_discovery.py +0 -0
  112. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_organization.py +0 -0
  113. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_color.py +0 -0
  114. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/__init__.py +0 -0
  115. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/conftest.py +0 -0
  116. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_base.py +0 -0
  117. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_hev.py +0 -0
  118. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_infrared.py +0 -0
  119. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_light.py +0 -0
  120. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_mac_address.py +0 -0
  121. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_matrix.py +0 -0
  122. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_multizone.py +0 -0
  123. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_ceiling.py +0 -0
  124. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_hev.py +0 -0
  125. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_infrared.py +0 -0
  126. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_light.py +0 -0
  127. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_management.py +0 -0
  128. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_matrix.py +0 -0
  129. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_multizone.py +0 -0
  130. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/__init__.py +0 -0
  131. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_base.py +0 -0
  132. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_capability_filtering.py +0 -0
  133. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_colorloop.py +0 -0
  134. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_models.py +0 -0
  135. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_pulse.py +0 -0
  136. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_state_manager.py +0 -0
  137. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/__init__.py +0 -0
  138. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_concurrent_requests.py +0 -0
  139. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_discovery_devices.py +0 -0
  140. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_discovery_errors.py +0 -0
  141. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_message.py +0 -0
  142. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_message_advanced.py +0 -0
  143. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_transport.py +0 -0
  144. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_products/test_product_generator.py +0 -0
  145. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_products/test_registry.py +0 -0
  146. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_protocol/test_generated.py +0 -0
  147. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_protocol/test_header.py +0 -0
  148. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_protocol/test_protocol_generator.py +0 -0
  149. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_protocol/test_serializer.py +0 -0
  150. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/__init__.py +0 -0
  151. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/conftest.py +0 -0
  152. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_apply_theme.py +0 -0
  153. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_generators.py +0 -0
  154. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_library.py +0 -0
  155. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_theme.py +0 -0
  156. {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 4.7.2
3
+ Version: 4.7.3
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.7.3 (2025-12-16)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **devices**: Capture component colors before set_power turns off light
10
+ ([`a99abee`](https://github.com/Djelibeybi/lifx-async/commit/a99abeeeb4f6cad1e49410204b8e7a567765b3ed))
11
+
12
+
5
13
  ## v4.7.2 (2025-12-16)
6
14
 
7
15
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-async"
3
- version = "4.7.2"
3
+ version = "4.7.3"
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"
@@ -619,6 +619,65 @@ class CeilingLight(MatrixLight):
619
619
  determined_colors = await self._determine_downlight_brightness()
620
620
  await self.set_downlight_colors(determined_colors, duration)
621
621
 
622
+ async def set_power(self, level: bool | int, duration: float = 0.0) -> None:
623
+ """Set light power state, capturing component colors before turning off.
624
+
625
+ Overrides Light.set_power() to capture the current uplight and downlight
626
+ colors before turning off the entire light. This allows subsequent calls
627
+ to turn_uplight_on() or turn_downlight_on() to restore the colors that
628
+ were active just before the light was turned off.
629
+
630
+ The captured colors preserve hue, saturation, and kelvin values even if
631
+ a component was already off (brightness=0). The brightness will be
632
+ determined at turn-on time using the standard brightness inference logic.
633
+
634
+ Args:
635
+ level: True/65535 to turn on, False/0 to turn off
636
+ duration: Transition duration in seconds (default 0.0)
637
+
638
+ Raises:
639
+ ValueError: If integer value is not 0 or 65535
640
+ LifxDeviceNotFoundError: If device is not connected
641
+ LifxTimeoutError: If device does not respond
642
+ LifxUnsupportedCommandError: If device doesn't support this command
643
+
644
+ Example:
645
+ ```python
646
+ # Turn off entire ceiling light (captures colors for later)
647
+ await ceiling.set_power(False)
648
+
649
+ # Later, turn on just the uplight with its previous color
650
+ await ceiling.turn_uplight_on()
651
+
652
+ # Or turn on just the downlight with its previous colors
653
+ await ceiling.turn_downlight_on()
654
+ ```
655
+ """
656
+ # Determine if we're turning off
657
+ if isinstance(level, bool):
658
+ turning_off = not level
659
+ elif isinstance(level, int):
660
+ if level not in (0, 65535):
661
+ raise ValueError(f"Power level must be 0 or 65535, got {level}")
662
+ turning_off = level == 0
663
+ else:
664
+ raise TypeError(f"Expected bool or int, got {type(level).__name__}")
665
+
666
+ # If turning off, capture current colors for both components
667
+ if turning_off:
668
+ # Always capture colors - even if brightness is 0, the hue/sat/kelvin
669
+ # are still useful for turn_on. Brightness will be determined at
670
+ # turn-on time using the standard inference logic.
671
+ self._stored_uplight_state = await self.get_uplight_color()
672
+ self._stored_downlight_state = await self.get_downlight_colors()
673
+
674
+ # Persist if enabled
675
+ if self._state_file:
676
+ self._save_state_to_file()
677
+
678
+ # Call parent to perform actual power change
679
+ await super().set_power(level, duration)
680
+
622
681
  async def turn_downlight_off(
623
682
  self, colors: HSBK | list[HSBK] | None = None, duration: float = 0.0
624
683
  ) -> None:
@@ -5,12 +5,13 @@ from __future__ import annotations
5
5
  import json
6
6
  import tempfile
7
7
  from pathlib import Path
8
- from unittest.mock import AsyncMock, MagicMock
8
+ from unittest.mock import AsyncMock, MagicMock, patch
9
9
 
10
10
  import pytest
11
11
 
12
12
  from lifx.color import HSBK
13
13
  from lifx.devices.ceiling import CeilingLight
14
+ from lifx.devices.matrix import MatrixLight
14
15
  from lifx.exceptions import LifxError
15
16
  from lifx.products import get_ceiling_layout
16
17
 
@@ -1646,3 +1647,237 @@ class TestCeilingLightSetDownlightSingleZeroBrightness:
1646
1647
 
1647
1648
  with pytest.raises(ValueError, match="brightness"):
1648
1649
  await ceiling_176.set_downlight_colors(invalid_color)
1650
+
1651
+
1652
+ class TestCeilingLightSetPowerOverride:
1653
+ """Tests for set_power() override in CeilingLight."""
1654
+
1655
+ @pytest.fixture
1656
+ def ceiling_176(self) -> CeilingLight:
1657
+ """Create a Ceiling product 176 instance with mocked connection."""
1658
+ ceiling = CeilingLight(serial="d073d5010203", ip="192.168.1.100")
1659
+ ceiling.connection = AsyncMock()
1660
+ ceiling.set_matrix_colors = AsyncMock()
1661
+ ceiling._save_state_to_file = MagicMock()
1662
+
1663
+ white = HSBK(hue=0, saturation=0.0, brightness=1.0, kelvin=3500)
1664
+ default_tile_colors = [white] * 64
1665
+ ceiling.get_all_tile_colors = AsyncMock(return_value=[default_tile_colors])
1666
+
1667
+ ceiling._version = MagicMock()
1668
+ ceiling._version.product = 176
1669
+ return ceiling
1670
+
1671
+ async def test_set_power_off_captures_uplight_color(
1672
+ self, ceiling_176: CeilingLight
1673
+ ) -> None:
1674
+ """Test set_power(False) captures uplight color before turning off."""
1675
+ # Setup: uplight is on with a specific color
1676
+ uplight_color = HSBK(hue=120, saturation=0.8, brightness=0.75, kelvin=4000)
1677
+ ceiling_176.get_uplight_color = AsyncMock(return_value=uplight_color)
1678
+
1679
+ # Downlight is off (brightness=0) but still has hue/sat/kelvin
1680
+ downlight_off = [
1681
+ HSBK(hue=200, saturation=0.5, brightness=0.0, kelvin=3500)
1682
+ ] * 16
1683
+ ceiling_176.get_downlight_colors = AsyncMock(return_value=downlight_off)
1684
+
1685
+ # Mock the parent set_power call
1686
+ with patch.object(
1687
+ MatrixLight, "set_power", new_callable=AsyncMock
1688
+ ) as mock_parent:
1689
+ await ceiling_176.set_power(False)
1690
+
1691
+ # Parent should be called
1692
+ mock_parent.assert_called_once_with(False, 0.0)
1693
+
1694
+ # Both colors should be stored (regardless of brightness)
1695
+ assert ceiling_176._stored_uplight_state == uplight_color
1696
+ assert ceiling_176._stored_downlight_state == downlight_off
1697
+
1698
+ # State file should NOT be saved (no state_file set in fixture)
1699
+ ceiling_176._save_state_to_file.assert_not_called()
1700
+
1701
+ async def test_set_power_off_captures_downlight_colors(
1702
+ self, ceiling_176: CeilingLight
1703
+ ) -> None:
1704
+ """Test set_power(False) captures downlight colors before turning off."""
1705
+ # Setup: uplight is off (brightness=0) but still has hue/sat/kelvin
1706
+ uplight_off = HSBK(hue=60, saturation=0.3, brightness=0.0, kelvin=3500)
1707
+ ceiling_176.get_uplight_color = AsyncMock(return_value=uplight_off)
1708
+
1709
+ # Downlight is on with specific colors
1710
+ downlight_colors = [
1711
+ HSBK(hue=240, saturation=1.0, brightness=0.9, kelvin=3500)
1712
+ ] * 16
1713
+ ceiling_176.get_downlight_colors = AsyncMock(return_value=downlight_colors)
1714
+
1715
+ with patch.object(
1716
+ MatrixLight, "set_power", new_callable=AsyncMock
1717
+ ) as mock_parent:
1718
+ await ceiling_176.set_power(False)
1719
+ mock_parent.assert_called_once_with(False, 0.0)
1720
+
1721
+ # Both should be stored (regardless of brightness)
1722
+ assert ceiling_176._stored_uplight_state == uplight_off
1723
+ assert ceiling_176._stored_downlight_state == downlight_colors
1724
+
1725
+ async def test_set_power_off_captures_both_components(
1726
+ self, ceiling_176: CeilingLight
1727
+ ) -> None:
1728
+ """Test set_power(False) captures both components when both are on."""
1729
+ uplight_color = HSBK(hue=60, saturation=0.5, brightness=1.0, kelvin=5000)
1730
+ ceiling_176.get_uplight_color = AsyncMock(return_value=uplight_color)
1731
+
1732
+ downlight_colors = [
1733
+ HSBK(hue=180, saturation=0.7, brightness=0.8, kelvin=4000)
1734
+ ] * 16
1735
+ ceiling_176.get_downlight_colors = AsyncMock(return_value=downlight_colors)
1736
+
1737
+ with patch.object(
1738
+ MatrixLight, "set_power", new_callable=AsyncMock
1739
+ ) as mock_parent:
1740
+ await ceiling_176.set_power(False)
1741
+ mock_parent.assert_called_once_with(False, 0.0)
1742
+
1743
+ # Both should be stored
1744
+ assert ceiling_176._stored_uplight_state == uplight_color
1745
+ assert ceiling_176._stored_downlight_state == downlight_colors
1746
+
1747
+ async def test_set_power_off_with_duration(self, ceiling_176: CeilingLight) -> None:
1748
+ """Test set_power(False) passes duration to parent."""
1749
+ uplight_color = HSBK(hue=0, saturation=0, brightness=0.5, kelvin=3500)
1750
+ ceiling_176.get_uplight_color = AsyncMock(return_value=uplight_color)
1751
+ ceiling_176.get_downlight_colors = AsyncMock(
1752
+ return_value=[HSBK(hue=0, saturation=0, brightness=0.0, kelvin=3500)] * 16
1753
+ )
1754
+
1755
+ with patch.object(
1756
+ MatrixLight, "set_power", new_callable=AsyncMock
1757
+ ) as mock_parent:
1758
+ await ceiling_176.set_power(False, duration=2.5)
1759
+ mock_parent.assert_called_once_with(False, 2.5)
1760
+
1761
+ async def test_set_power_on_does_not_capture(
1762
+ self, ceiling_176: CeilingLight
1763
+ ) -> None:
1764
+ """Test set_power(True) does NOT capture colors (only off captures)."""
1765
+ # Pre-set stored states
1766
+ ceiling_176._stored_uplight_state = HSBK(
1767
+ hue=100, saturation=0.5, brightness=0.5, kelvin=3500
1768
+ )
1769
+ ceiling_176._stored_downlight_state = [
1770
+ HSBK(hue=200, saturation=0.5, brightness=0.5, kelvin=3500)
1771
+ ] * 16
1772
+
1773
+ with patch.object(
1774
+ MatrixLight, "set_power", new_callable=AsyncMock
1775
+ ) as mock_parent:
1776
+ await ceiling_176.set_power(True)
1777
+ mock_parent.assert_called_once_with(True, 0.0)
1778
+
1779
+ # get_uplight_color and get_downlight_colors should NOT be called
1780
+ # (they weren't mocked, so they would fail if called)
1781
+
1782
+ # Stored states should remain unchanged
1783
+ assert ceiling_176._stored_uplight_state.hue == 100
1784
+ assert ceiling_176._stored_downlight_state[0].hue == 200
1785
+
1786
+ async def test_set_power_with_integer_off(self, ceiling_176: CeilingLight) -> None:
1787
+ """Test set_power(0) captures colors (integer form)."""
1788
+ uplight_color = HSBK(hue=300, saturation=0.9, brightness=0.6, kelvin=3000)
1789
+ ceiling_176.get_uplight_color = AsyncMock(return_value=uplight_color)
1790
+
1791
+ downlight_colors = [
1792
+ HSBK(hue=180, saturation=0.5, brightness=0.0, kelvin=3500)
1793
+ ] * 16
1794
+ ceiling_176.get_downlight_colors = AsyncMock(return_value=downlight_colors)
1795
+
1796
+ with patch.object(
1797
+ MatrixLight, "set_power", new_callable=AsyncMock
1798
+ ) as mock_parent:
1799
+ await ceiling_176.set_power(0)
1800
+ mock_parent.assert_called_once_with(0, 0.0)
1801
+
1802
+ # Both should be stored regardless of brightness
1803
+ assert ceiling_176._stored_uplight_state == uplight_color
1804
+ assert ceiling_176._stored_downlight_state == downlight_colors
1805
+
1806
+ async def test_set_power_with_integer_on(self, ceiling_176: CeilingLight) -> None:
1807
+ """Test set_power(65535) does not capture colors (integer form)."""
1808
+ with patch.object(
1809
+ MatrixLight, "set_power", new_callable=AsyncMock
1810
+ ) as mock_parent:
1811
+ await ceiling_176.set_power(65535)
1812
+ mock_parent.assert_called_once_with(65535, 0.0)
1813
+
1814
+ async def test_set_power_invalid_integer_raises(
1815
+ self, ceiling_176: CeilingLight
1816
+ ) -> None:
1817
+ """Test set_power with invalid integer raises ValueError."""
1818
+ with pytest.raises(ValueError, match="Power level must be 0 or 65535"):
1819
+ await ceiling_176.set_power(100)
1820
+
1821
+ async def test_set_power_invalid_type_raises(
1822
+ self, ceiling_176: CeilingLight
1823
+ ) -> None:
1824
+ """Test set_power with invalid type raises TypeError."""
1825
+ with pytest.raises(TypeError, match="Expected bool or int"):
1826
+ await ceiling_176.set_power("on") # type: ignore[arg-type]
1827
+
1828
+ async def test_set_power_off_persists_state_to_file(
1829
+ self, ceiling_176: CeilingLight
1830
+ ) -> None:
1831
+ """Test set_power(False) saves state to file when state_file is set."""
1832
+ ceiling_176._state_file = "/tmp/test_state.json"
1833
+
1834
+ uplight_color = HSBK(hue=45, saturation=0.3, brightness=0.7, kelvin=4500)
1835
+ ceiling_176.get_uplight_color = AsyncMock(return_value=uplight_color)
1836
+ ceiling_176.get_downlight_colors = AsyncMock(
1837
+ return_value=[HSBK(hue=0, saturation=0, brightness=0.0, kelvin=3500)] * 16
1838
+ )
1839
+
1840
+ with patch.object(MatrixLight, "set_power", new_callable=AsyncMock):
1841
+ await ceiling_176.set_power(False)
1842
+
1843
+ ceiling_176._save_state_to_file.assert_called_once()
1844
+
1845
+ async def test_set_power_off_no_persist_without_state_file(
1846
+ self, ceiling_176: CeilingLight
1847
+ ) -> None:
1848
+ """Test set_power(False) doesn't save state when state_file is None."""
1849
+ ceiling_176._state_file = None
1850
+ ceiling_176._save_state_to_file.reset_mock()
1851
+
1852
+ uplight_color = HSBK(hue=45, saturation=0.3, brightness=0.7, kelvin=4500)
1853
+ ceiling_176.get_uplight_color = AsyncMock(return_value=uplight_color)
1854
+ ceiling_176.get_downlight_colors = AsyncMock(
1855
+ return_value=[HSBK(hue=0, saturation=0, brightness=0.0, kelvin=3500)] * 16
1856
+ )
1857
+
1858
+ with patch.object(MatrixLight, "set_power", new_callable=AsyncMock):
1859
+ await ceiling_176.set_power(False)
1860
+
1861
+ ceiling_176._save_state_to_file.assert_not_called()
1862
+
1863
+ async def test_set_power_workflow_off_then_turn_on_uplight(
1864
+ self, ceiling_176: CeilingLight
1865
+ ) -> None:
1866
+ """Test workflow: set_power(off) -> turn_uplight_on() restores color."""
1867
+ # Initial uplight color before turning off
1868
+ uplight_color = HSBK(hue=120, saturation=0.8, brightness=0.75, kelvin=4000)
1869
+ ceiling_176.get_uplight_color = AsyncMock(return_value=uplight_color)
1870
+ ceiling_176.get_downlight_colors = AsyncMock(
1871
+ return_value=[HSBK(hue=0, saturation=0, brightness=0.0, kelvin=3500)] * 16
1872
+ )
1873
+
1874
+ # Turn off entire light
1875
+ with patch.object(MatrixLight, "set_power", new_callable=AsyncMock):
1876
+ await ceiling_176.set_power(False)
1877
+
1878
+ # Verify uplight was stored
1879
+ assert ceiling_176._stored_uplight_state == uplight_color
1880
+
1881
+ # Now _determine_uplight_brightness should return stored color
1882
+ # (simulate what turn_uplight_on() would do)
1883
+ assert ceiling_176._stored_uplight_state.brightness > 0
@@ -1,6 +1,7 @@
1
1
  """Integration tests for effects system."""
2
2
 
3
3
  import asyncio
4
+ import time
4
5
  from unittest.mock import AsyncMock, MagicMock
5
6
 
6
7
  import pytest
@@ -9,6 +10,56 @@ from lifx.color import HSBK
9
10
  from lifx.effects import Conductor, EffectColorloop, EffectPulse
10
11
 
11
12
 
13
+ async def wait_for_mock_called(
14
+ mock: MagicMock, timeout: float = 1.0, poll_interval: float = 0.01
15
+ ) -> None:
16
+ """Wait for a mock to be called, with timeout.
17
+
18
+ This is more reliable than fixed sleeps on slow CI systems (especially Windows).
19
+
20
+ Args:
21
+ mock: The mock object to check
22
+ timeout: Maximum time to wait in seconds
23
+ poll_interval: Time between checks in seconds
24
+
25
+ Raises:
26
+ AssertionError: If mock was not called within timeout
27
+ """
28
+ start = time.monotonic()
29
+ while time.monotonic() - start < timeout:
30
+ if mock.call_count > 0:
31
+ return
32
+ await asyncio.sleep(poll_interval)
33
+ raise AssertionError(f"Expected mock to be called within {timeout}s")
34
+
35
+
36
+ async def wait_for_effect_complete(
37
+ conductor: Conductor,
38
+ light: MagicMock,
39
+ timeout: float = 2.0,
40
+ poll_interval: float = 0.05,
41
+ ) -> None:
42
+ """Wait for an effect to complete and be removed from registry.
43
+
44
+ This is more reliable than fixed sleeps on slow CI systems (especially Windows).
45
+
46
+ Args:
47
+ conductor: The Conductor instance
48
+ light: The light to check
49
+ timeout: Maximum time to wait in seconds
50
+ poll_interval: Time between checks in seconds
51
+
52
+ Raises:
53
+ AssertionError: If effect was not removed within timeout
54
+ """
55
+ start = time.monotonic()
56
+ while time.monotonic() - start < timeout:
57
+ if conductor.effect(light) is None:
58
+ return
59
+ await asyncio.sleep(poll_interval)
60
+ raise AssertionError(f"Expected effect to complete within {timeout}s")
61
+
62
+
12
63
  @pytest.fixture
13
64
  def conductor():
14
65
  """Create a Conductor instance."""
@@ -114,10 +165,9 @@ async def test_pulse_effect_strobe_mode(conductor, mock_light):
114
165
  effect = EffectPulse(mode="strobe", cycles=2, period=0.05)
115
166
 
116
167
  await conductor.start(effect, [mock_light])
117
- await asyncio.sleep(0.02)
118
168
 
119
- # Verify waveform called
120
- mock_light.set_waveform.assert_called()
169
+ # Use polling instead of fixed sleep - more reliable on slow CI systems
170
+ await wait_for_mock_called(mock_light.set_waveform, timeout=1.0)
121
171
 
122
172
  await conductor.stop([mock_light])
123
173
 
@@ -306,12 +356,9 @@ async def test_effect_completion_restores_state(conductor, mock_light):
306
356
 
307
357
  await conductor.start(effect, [mock_light])
308
358
 
309
- # Wait for effect to complete (period * cycles + buffer)
310
- # Give extra time for effect completion and cleanup
311
- await asyncio.sleep(0.5)
312
-
313
- # Effect should have completed and been removed from running registry
314
- assert conductor.effect(mock_light) is None
359
+ # Use polling instead of fixed sleep - more reliable on slow CI systems
360
+ # The effect should complete within ~0.1s but we allow up to 2s for CI variability
361
+ await wait_for_effect_complete(conductor, mock_light, timeout=2.0)
315
362
 
316
363
 
317
364
  @pytest.mark.asyncio
@@ -169,8 +169,10 @@ class TestDeviceConnection:
169
169
 
170
170
  # If truly concurrent, total time should be ~0.1s (one sleep duration)
171
171
  # If serialized, it would be ~0.2s (two sleep durations)
172
- # Allow some overhead, but verify concurrency
173
- assert total_time < 0.15, (
172
+ # Use generous tolerance (0.19s) to account for CI variability
173
+ # (especially on macOS where timing can be less precise under load)
174
+ # Anything under 0.2s proves concurrency since serial would be >= 0.2s
175
+ assert total_time < 0.19, (
174
176
  f"Requests took too long ({total_time}s), suggesting serialization"
175
177
  )
176
178
 
@@ -122,9 +122,11 @@ class TestShufflePoints:
122
122
  def test_shuffle_preserves_point_count(self) -> None:
123
123
  """Test that shuffle_points preserves number of points."""
124
124
  canvas = Canvas()
125
+ # Use points spaced far apart (10 units) to avoid collision after shuffle.
126
+ # shuffle_point() moves each point by ±3, so points 7+ apart can't collide.
125
127
  canvas[(0, 0)] = Colors.RED
126
- canvas[(5, 5)] = Colors.GREEN
127
- canvas[(10, 10)] = Colors.BLUE
128
+ canvas[(10, 10)] = Colors.GREEN
129
+ canvas[(20, 20)] = Colors.BLUE
128
130
 
129
131
  original_count = len(canvas.points)
130
132
  canvas.shuffle_points()
@@ -337,7 +337,7 @@ wheels = [
337
337
 
338
338
  [[package]]
339
339
  name = "lifx-async"
340
- version = "4.7.2"
340
+ version = "4.7.3"
341
341
  source = { editable = "." }
342
342
 
343
343
  [package.dev-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes