lifx-async 4.7.2__tar.gz → 4.7.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 (156) hide show
  1. {lifx_async-4.7.2 → lifx_async-4.7.4}/PKG-INFO +1 -1
  2. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/changelog.md +16 -0
  3. {lifx_async-4.7.2 → lifx_async-4.7.4}/pyproject.toml +1 -1
  4. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/devices/ceiling.py +340 -97
  5. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_ceiling.py +603 -15
  6. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_effects/test_integration.py +56 -9
  7. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_network/test_connection.py +4 -2
  8. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_theme/test_canvas.py +4 -2
  9. {lifx_async-4.7.2 → lifx_async-4.7.4}/uv.lock +1 -1
  10. {lifx_async-4.7.2 → lifx_async-4.7.4}/.claude/settings.json +0 -0
  11. {lifx_async-4.7.2 → lifx_async-4.7.4}/.github/dependabot.yml +0 -0
  12. {lifx_async-4.7.2 → lifx_async-4.7.4}/.github/labeler.yml +0 -0
  13. {lifx_async-4.7.2 → lifx_async-4.7.4}/.github/workflows/ci.yml +0 -0
  14. {lifx_async-4.7.2 → lifx_async-4.7.4}/.github/workflows/docs.yml +0 -0
  15. {lifx_async-4.7.2 → lifx_async-4.7.4}/.github/workflows/pr-automation.yml +0 -0
  16. {lifx_async-4.7.2 → lifx_async-4.7.4}/.gitignore +0 -0
  17. {lifx_async-4.7.2 → lifx_async-4.7.4}/.pre-commit-config.yaml +0 -0
  18. {lifx_async-4.7.2 → lifx_async-4.7.4}/CLAUDE.md +0 -0
  19. {lifx_async-4.7.2 → lifx_async-4.7.4}/LICENSE +0 -0
  20. {lifx_async-4.7.2 → lifx_async-4.7.4}/README.md +0 -0
  21. {lifx_async-4.7.2 → lifx_async-4.7.4}/context7.json +0 -0
  22. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/colors.md +0 -0
  23. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/devices.md +0 -0
  24. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/effects.md +0 -0
  25. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/exceptions.md +0 -0
  26. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/high-level.md +0 -0
  27. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/index.md +0 -0
  28. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/network.md +0 -0
  29. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/protocol.md +0 -0
  30. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/api/themes.md +0 -0
  31. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/architecture/effects-architecture.md +0 -0
  32. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/architecture/overview.md +0 -0
  33. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/faq.md +0 -0
  34. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/getting-started/effects.md +0 -0
  35. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/getting-started/installation.md +0 -0
  36. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/getting-started/quickstart.md +0 -0
  37. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/getting-started/themes.md +0 -0
  38. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/index.md +0 -0
  39. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/migration/effect-api-changes.md +0 -0
  40. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/stylesheets/extra.css +0 -0
  41. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/user-guide/advanced-usage.md +0 -0
  42. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/user-guide/ceiling-lights.md +0 -0
  43. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/user-guide/effects-custom.md +0 -0
  44. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/user-guide/effects-troubleshooting.md +0 -0
  45. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/user-guide/protocol-deep-dive.md +0 -0
  46. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/user-guide/themes.md +0 -0
  47. {lifx_async-4.7.2 → lifx_async-4.7.4}/docs/user-guide/troubleshooting.md +0 -0
  48. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/01_simple_discovery.py +0 -0
  49. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/02_simple_control.py +0 -0
  50. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/03_waveforms.py +0 -0
  51. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/04_logging.py +0 -0
  52. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/06_pulse_effect.py +0 -0
  53. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/07_colorloop_effect.py +0 -0
  54. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/08_custom_effect.py +0 -0
  55. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/09_background_effect.py +0 -0
  56. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/10_find_specific_devices.py +0 -0
  57. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/11_matrix_basic.py +0 -0
  58. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/12_matrix_effects.py +0 -0
  59. {lifx_async-4.7.2 → lifx_async-4.7.4}/examples/13_matrix_large.py +0 -0
  60. {lifx_async-4.7.2 → lifx_async-4.7.4}/mkdocs.yml +0 -0
  61. {lifx_async-4.7.2 → lifx_async-4.7.4}/renovate.json +0 -0
  62. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/__init__.py +0 -0
  63. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/api.py +0 -0
  64. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/color.py +0 -0
  65. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/const.py +0 -0
  66. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/devices/__init__.py +0 -0
  67. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/devices/base.py +0 -0
  68. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/devices/hev.py +0 -0
  69. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/devices/infrared.py +0 -0
  70. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/devices/light.py +0 -0
  71. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/devices/matrix.py +0 -0
  72. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/devices/multizone.py +0 -0
  73. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/effects/__init__.py +0 -0
  74. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/effects/base.py +0 -0
  75. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/effects/colorloop.py +0 -0
  76. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/effects/conductor.py +0 -0
  77. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/effects/const.py +0 -0
  78. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/effects/models.py +0 -0
  79. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/effects/pulse.py +0 -0
  80. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/effects/state_manager.py +0 -0
  81. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/exceptions.py +0 -0
  82. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/network/__init__.py +0 -0
  83. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/network/connection.py +0 -0
  84. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/network/discovery.py +0 -0
  85. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/network/message.py +0 -0
  86. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/network/transport.py +0 -0
  87. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/products/__init__.py +0 -0
  88. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/products/generator.py +0 -0
  89. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/products/quirks.py +0 -0
  90. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/products/registry.py +0 -0
  91. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/protocol/__init__.py +0 -0
  92. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/protocol/base.py +0 -0
  93. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/protocol/generator.py +0 -0
  94. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/protocol/header.py +0 -0
  95. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/protocol/models.py +0 -0
  96. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/protocol/packets.py +0 -0
  97. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/protocol/protocol_types.py +0 -0
  98. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/protocol/serializer.py +0 -0
  99. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/py.typed +0 -0
  100. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/theme/__init__.py +0 -0
  101. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/theme/canvas.py +0 -0
  102. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/theme/generators.py +0 -0
  103. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/theme/library.py +0 -0
  104. {lifx_async-4.7.2 → lifx_async-4.7.4}/src/lifx/theme/theme.py +0 -0
  105. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/__init__.py +0 -0
  106. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/conftest.py +0 -0
  107. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_api/__init__.py +0 -0
  108. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_api/test_api_apply_theme.py +0 -0
  109. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_api/test_api_batch_errors.py +0 -0
  110. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_api/test_api_batch_operations.py +0 -0
  111. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_api/test_api_discovery.py +0 -0
  112. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_api/test_api_organization.py +0 -0
  113. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_color.py +0 -0
  114. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/__init__.py +0 -0
  115. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/conftest.py +0 -0
  116. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_base.py +0 -0
  117. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_hev.py +0 -0
  118. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_infrared.py +0 -0
  119. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_light.py +0 -0
  120. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_mac_address.py +0 -0
  121. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_matrix.py +0 -0
  122. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_multizone.py +0 -0
  123. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_state_ceiling.py +0 -0
  124. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_state_hev.py +0 -0
  125. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_state_infrared.py +0 -0
  126. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_state_light.py +0 -0
  127. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_state_management.py +0 -0
  128. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_state_matrix.py +0 -0
  129. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_devices/test_state_multizone.py +0 -0
  130. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_effects/__init__.py +0 -0
  131. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_effects/test_base.py +0 -0
  132. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_effects/test_capability_filtering.py +0 -0
  133. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_effects/test_colorloop.py +0 -0
  134. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_effects/test_models.py +0 -0
  135. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_effects/test_pulse.py +0 -0
  136. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_effects/test_state_manager.py +0 -0
  137. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_network/__init__.py +0 -0
  138. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_network/test_concurrent_requests.py +0 -0
  139. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_network/test_discovery_devices.py +0 -0
  140. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_network/test_discovery_errors.py +0 -0
  141. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_network/test_message.py +0 -0
  142. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_network/test_message_advanced.py +0 -0
  143. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_network/test_transport.py +0 -0
  144. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_products/test_product_generator.py +0 -0
  145. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_products/test_registry.py +0 -0
  146. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_protocol/test_generated.py +0 -0
  147. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_protocol/test_header.py +0 -0
  148. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_protocol/test_protocol_generator.py +0 -0
  149. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_protocol/test_serializer.py +0 -0
  150. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_theme/__init__.py +0 -0
  151. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_theme/conftest.py +0 -0
  152. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_theme/test_apply_theme.py +0 -0
  153. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_theme/test_generators.py +0 -0
  154. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_theme/test_library.py +0 -0
  155. {lifx_async-4.7.2 → lifx_async-4.7.4}/tests/test_theme/test_theme.py +0 -0
  156. {lifx_async-4.7.2 → lifx_async-4.7.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.7.2
3
+ Version: 4.7.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,22 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v4.7.4 (2025-12-16)
6
+
7
+ ### Performance Improvements
8
+
9
+ - **devices**: Reduce get_all_tile_colors calls in CeilingLight
10
+ ([`3936158`](https://github.com/Djelibeybi/lifx-async/commit/39361582856fcde57f30f052b8286f0bbb695f67))
11
+
12
+
13
+ ## v4.7.3 (2025-12-16)
14
+
15
+ ### Bug Fixes
16
+
17
+ - **devices**: Capture component colors before set_power turns off light
18
+ ([`a99abee`](https://github.com/Djelibeybi/lifx-async/commit/a99abeeeb4f6cad1e49410204b8e7a567765b3ed))
19
+
20
+
5
21
  ## v4.7.2 (2025-12-16)
6
22
 
7
23
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-async"
3
- version = "4.7.2"
3
+ version = "4.7.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"
@@ -201,9 +201,15 @@ class CeilingLight(MatrixLight):
201
201
  """
202
202
  matrix_state = await super()._initialize_state()
203
203
 
204
- # Get ceiling component colors
205
- uplight_color = await self.get_uplight_color()
206
- downlight_colors = await self.get_downlight_colors()
204
+ # Extract ceiling component colors from already-fetched tile_colors
205
+ # (parent _initialize_state already called get_all_tile_colors)
206
+ tile_colors = matrix_state.tile_colors
207
+ uplight_color = tile_colors[self.uplight_zone]
208
+ downlight_colors = list(tile_colors[self.downlight_zones])
209
+
210
+ # Cache for is_on properties
211
+ self._last_uplight_color = uplight_color
212
+ self._last_downlight_colors = downlight_colors
207
213
 
208
214
  # Create ceiling state from matrix state
209
215
  ceiling_state = CeilingLightState.from_matrix_state(
@@ -231,9 +237,15 @@ class CeilingLight(MatrixLight):
231
237
  """
232
238
  await super().refresh_state()
233
239
 
234
- # Get ceiling component colors
235
- uplight_color = await self.get_uplight_color()
236
- downlight_colors = await self.get_downlight_colors()
240
+ # Extract ceiling component colors from already-fetched tile_colors
241
+ # (parent refresh_state already called get_all_tile_colors)
242
+ tile_colors = self._state.tile_colors
243
+ uplight_color = tile_colors[self.uplight_zone]
244
+ downlight_colors = list(tile_colors[self.downlight_zones])
245
+
246
+ # Cache for is_on properties
247
+ self._last_uplight_color = uplight_color
248
+ self._last_downlight_colors = downlight_colors
237
249
 
238
250
  # Update ceiling-specific state fields
239
251
  state = cast(CeilingLightState, self._state)
@@ -522,6 +534,10 @@ class CeilingLight(MatrixLight):
522
534
  ) -> None:
523
535
  """Turn uplight component on.
524
536
 
537
+ If the entire light is off, this will set the color instantly and then
538
+ turn on the light with the specified duration, so the light fades to
539
+ the target color instead of flashing to its previous state.
540
+
525
541
  Args:
526
542
  color: Optional HSBK color. If provided:
527
543
  - Uses this color immediately
@@ -533,14 +549,61 @@ class CeilingLight(MatrixLight):
533
549
  ValueError: If color.brightness == 0
534
550
  LifxTimeoutError: Device did not respond
535
551
  """
536
- if color is not None:
537
- if color.brightness == 0:
538
- raise ValueError("Cannot turn on uplight with brightness=0")
539
- await self.set_uplight_color(color, duration)
552
+ # Validate provided color early
553
+ if color is not None and color.brightness == 0:
554
+ raise ValueError("Cannot turn on uplight with brightness=0")
555
+
556
+ # Check if light is off first to determine which path to take
557
+ if await self.get_power() == 0:
558
+ # Light is off - single fetch for both determining color and modification
559
+ all_colors = await self.get_all_tile_colors()
560
+ tile_colors = all_colors[0]
561
+
562
+ # Determine target color (pass pre-fetched colors to avoid extra fetch)
563
+ if color is not None:
564
+ target_color = color
565
+ else:
566
+ target_color = await self._determine_uplight_brightness(tile_colors)
567
+
568
+ # Store current downlight colors BEFORE zeroing them out
569
+ # This allows turn_downlight_on() to restore them later
570
+ downlight_colors = tile_colors[self.downlight_zones]
571
+ self._stored_downlight_state = list(downlight_colors)
572
+
573
+ # Set uplight zone to target color
574
+ tile_colors[self.uplight_zone] = target_color
575
+
576
+ # Zero out downlight zones so they stay off when power turns on
577
+ for i in range(*self.downlight_zones.indices(len(tile_colors))):
578
+ tile_colors[i] = HSBK(
579
+ hue=tile_colors[i].hue,
580
+ saturation=tile_colors[i].saturation,
581
+ brightness=0.0,
582
+ kelvin=tile_colors[i].kelvin,
583
+ )
584
+
585
+ # Set all colors instantly (duration=0) while light is off
586
+ await self.set_matrix_colors(0, tile_colors, duration=0)
587
+
588
+ # Update stored state for uplight
589
+ self._stored_uplight_state = target_color
590
+ self._last_uplight_color = target_color
591
+
592
+ # Turn on with the requested duration - light fades on to target color
593
+ await super().set_power(True, duration)
594
+
595
+ # Persist AFTER device operations complete
596
+ if self._state_file:
597
+ self._save_state_to_file()
540
598
  else:
541
- # Determine color using priority logic
542
- determined_color = await self._determine_uplight_brightness()
543
- await self.set_uplight_color(determined_color, duration)
599
+ # Light is already on - determine target color first, then set
600
+ if color is not None:
601
+ target_color = color
602
+ else:
603
+ target_color = await self._determine_uplight_brightness()
604
+
605
+ # set_uplight_color will fetch and modify (single fetch in that method)
606
+ await self.set_uplight_color(target_color, duration)
544
607
 
545
608
  async def turn_uplight_off(
546
609
  self, color: HSBK | None = None, duration: float = 0.0
@@ -560,30 +623,35 @@ class CeilingLight(MatrixLight):
560
623
  Note:
561
624
  Sets uplight zone brightness to 0 on device while preserving H, S, K.
562
625
  """
626
+ if color is not None and color.brightness == 0:
627
+ raise ValueError(
628
+ "Provided color cannot have brightness=0. "
629
+ "Omit the parameter to use current color."
630
+ )
631
+
632
+ # Fetch current state once and reuse to calculate brightness
633
+ all_colors = await self.get_all_tile_colors()
634
+ tile_colors = all_colors[0]
635
+
636
+ # Determine which color to store
563
637
  if color is not None:
564
- if color.brightness == 0:
565
- raise ValueError(
566
- "Provided color cannot have brightness=0. "
567
- "Omit the parameter to use current color."
568
- )
569
- # Store the provided color
570
- self._stored_uplight_state = color
638
+ stored_color = color
571
639
  else:
572
- # Get and store current color
573
- current_color = await self.get_uplight_color()
574
- self._stored_uplight_state = current_color
640
+ stored_color = tile_colors[self.uplight_zone]
641
+ self._last_uplight_color = stored_color
642
+
643
+ # Store for future restoration
644
+ self._stored_uplight_state = stored_color
575
645
 
576
646
  # Create color with brightness=0 for device
577
647
  off_color = HSBK(
578
- hue=self._stored_uplight_state.hue,
579
- saturation=self._stored_uplight_state.saturation,
648
+ hue=stored_color.hue,
649
+ saturation=stored_color.saturation,
580
650
  brightness=0.0,
581
- kelvin=self._stored_uplight_state.kelvin,
651
+ kelvin=stored_color.kelvin,
582
652
  )
583
653
 
584
- # Get all colors and update uplight zone
585
- all_colors = await self.get_all_tile_colors()
586
- tile_colors = all_colors[0]
654
+ # Update uplight zone and send immediately
587
655
  tile_colors[self.uplight_zone] = off_color
588
656
  await self.set_matrix_colors(0, tile_colors, duration=int(duration * 1000))
589
657
 
@@ -599,6 +667,10 @@ class CeilingLight(MatrixLight):
599
667
  ) -> None:
600
668
  """Turn downlight component on.
601
669
 
670
+ If the entire light is off, this will set the colors instantly and then
671
+ turn on the light with the specified duration, so the light fades to
672
+ the target colors instead of flashing to its previous state.
673
+
602
674
  Args:
603
675
  colors: Optional colors. Can be:
604
676
  - None: uses brightness determination logic
@@ -612,12 +684,145 @@ class CeilingLight(MatrixLight):
612
684
  ValueError: If list length doesn't match downlight zone count
613
685
  LifxTimeoutError: Device did not respond
614
686
  """
687
+ # Number of downlight zones equals the uplight zone index
688
+ # (downlight is zones 0 to uplight_zone-1)
689
+ downlight_zone_count = self.uplight_zone
690
+
691
+ # Validate provided colors early
615
692
  if colors is not None:
616
- await self.set_downlight_colors(colors, duration)
693
+ if isinstance(colors, HSBK):
694
+ if colors.brightness == 0:
695
+ raise ValueError("Cannot turn on downlight with brightness=0")
696
+ else:
697
+ if all(c.brightness == 0 for c in colors):
698
+ raise ValueError("Cannot turn on downlight with brightness=0")
699
+ if len(colors) != downlight_zone_count:
700
+ raise ValueError(
701
+ f"Expected {downlight_zone_count} colors for downlight, "
702
+ f"got {len(colors)}"
703
+ )
704
+
705
+ # Check if light is off first to determine which path to take
706
+ if await self.get_power() == 0:
707
+ # Light is off - single fetch for both determining colors and modification
708
+ all_colors = await self.get_all_tile_colors()
709
+ tile_colors = all_colors[0]
710
+
711
+ # Determine target colors (pass pre-fetched colors to avoid extra fetch)
712
+ if colors is not None:
713
+ if isinstance(colors, HSBK):
714
+ target_colors = [colors] * downlight_zone_count
715
+ else:
716
+ target_colors = list(colors)
717
+ else:
718
+ target_colors = await self._determine_downlight_brightness(tile_colors)
719
+
720
+ # Store current uplight color BEFORE zeroing it out
721
+ # This allows turn_uplight_on() to restore it later
722
+ self._stored_uplight_state = tile_colors[self.uplight_zone]
723
+
724
+ # Set downlight zones to target colors
725
+ tile_colors[self.downlight_zones] = target_colors
726
+
727
+ # Zero out uplight zone so it stays off when power turns on
728
+ uplight_color = tile_colors[self.uplight_zone]
729
+ tile_colors[self.uplight_zone] = HSBK(
730
+ hue=uplight_color.hue,
731
+ saturation=uplight_color.saturation,
732
+ brightness=0.0,
733
+ kelvin=uplight_color.kelvin,
734
+ )
735
+
736
+ # Set all colors instantly (duration=0) while light is off
737
+ await self.set_matrix_colors(0, tile_colors, duration=0)
738
+
739
+ # Update stored state for downlight
740
+ self._stored_downlight_state = target_colors
741
+ self._last_downlight_colors = target_colors
742
+
743
+ # Turn on with the requested duration - light fades on to target colors
744
+ await super().set_power(True, duration)
745
+
746
+ # Persist AFTER device operations complete
747
+ if self._state_file:
748
+ self._save_state_to_file()
749
+ else:
750
+ # Light is already on - determine target colors first, then set
751
+ if colors is not None:
752
+ if isinstance(colors, HSBK):
753
+ target_colors = [colors] * downlight_zone_count
754
+ else:
755
+ target_colors = list(colors)
756
+ else:
757
+ target_colors = await self._determine_downlight_brightness()
758
+
759
+ # set_downlight_colors will fetch and modify (single fetch in that method)
760
+ await self.set_downlight_colors(target_colors, duration)
761
+
762
+ async def set_power(self, level: bool | int, duration: float = 0.0) -> None:
763
+ """Set light power state, capturing component colors before turning off.
764
+
765
+ Overrides Light.set_power() to capture the current uplight and downlight
766
+ colors before turning off the entire light. This allows subsequent calls
767
+ to turn_uplight_on() or turn_downlight_on() to restore the colors that
768
+ were active just before the light was turned off.
769
+
770
+ The captured colors preserve hue, saturation, and kelvin values even if
771
+ a component was already off (brightness=0). The brightness will be
772
+ determined at turn-on time using the standard brightness inference logic.
773
+
774
+ Args:
775
+ level: True/65535 to turn on, False/0 to turn off
776
+ duration: Transition duration in seconds (default 0.0)
777
+
778
+ Raises:
779
+ ValueError: If integer value is not 0 or 65535
780
+ LifxDeviceNotFoundError: If device is not connected
781
+ LifxTimeoutError: If device does not respond
782
+ LifxUnsupportedCommandError: If device doesn't support this command
783
+
784
+ Example:
785
+ ```python
786
+ # Turn off entire ceiling light (captures colors for later)
787
+ await ceiling.set_power(False)
788
+
789
+ # Later, turn on just the uplight with its previous color
790
+ await ceiling.turn_uplight_on()
791
+
792
+ # Or turn on just the downlight with its previous colors
793
+ await ceiling.turn_downlight_on()
794
+ ```
795
+ """
796
+ # Determine if we're turning off
797
+ if isinstance(level, bool):
798
+ turning_off = not level
799
+ elif isinstance(level, int):
800
+ if level not in (0, 65535):
801
+ raise ValueError(f"Power level must be 0 or 65535, got {level}")
802
+ turning_off = level == 0
617
803
  else:
618
- # Determine colors using priority logic
619
- determined_colors = await self._determine_downlight_brightness()
620
- await self.set_downlight_colors(determined_colors, duration)
804
+ raise TypeError(f"Expected bool or int, got {type(level).__name__}")
805
+
806
+ # If turning off, capture current colors for both components with single fetch
807
+ if turning_off:
808
+ # Single fetch to capture both uplight and downlight colors
809
+ all_colors = await self.get_all_tile_colors()
810
+ tile_colors = all_colors[0]
811
+
812
+ # Extract and store both component colors
813
+ self._stored_uplight_state = tile_colors[self.uplight_zone]
814
+ self._stored_downlight_state = list(tile_colors[self.downlight_zones])
815
+
816
+ # Also update cache for is_on properties
817
+ self._last_uplight_color = self._stored_uplight_state
818
+ self._last_downlight_colors = self._stored_downlight_state
819
+
820
+ # Call parent to perform actual power change
821
+ await super().set_power(level, duration)
822
+
823
+ # Persist AFTER device operation completes
824
+ if turning_off and self._state_file:
825
+ self._save_state_to_file()
621
826
 
622
827
  async def turn_downlight_off(
623
828
  self, colors: HSBK | list[HSBK] | None = None, duration: float = 0.0
@@ -642,15 +847,16 @@ class CeilingLight(MatrixLight):
642
847
  """
643
848
  expected_count = len(range(*self.downlight_zones.indices(256)))
644
849
 
850
+ # Validate provided colors early (before fetching)
851
+ stored_colors: list[HSBK] | None = None
645
852
  if colors is not None:
646
- # Validate and normalize provided colors
647
853
  if isinstance(colors, HSBK):
648
854
  if colors.brightness == 0:
649
855
  raise ValueError(
650
856
  "Provided color cannot have brightness=0. "
651
857
  "Omit the parameter to use current colors."
652
858
  )
653
- colors_to_store = [colors] * expected_count
859
+ stored_colors = [colors] * expected_count
654
860
  else:
655
861
  if all(c.brightness == 0 for c in colors):
656
862
  raise ValueError(
@@ -662,13 +868,19 @@ class CeilingLight(MatrixLight):
662
868
  f"Expected {expected_count} colors for downlight, "
663
869
  f"got {len(colors)}"
664
870
  )
665
- colors_to_store = colors
871
+ stored_colors = list(colors)
666
872
 
667
- self._stored_downlight_state = colors_to_store
668
- else:
669
- # Get and store current colors
670
- current_colors = await self.get_downlight_colors()
671
- self._stored_downlight_state = current_colors
873
+ # Fetch current state once and reuse to calculate brightness
874
+ all_colors = await self.get_all_tile_colors()
875
+ tile_colors = all_colors[0]
876
+
877
+ # If colors not provided, extract from fetched data
878
+ if stored_colors is None:
879
+ stored_colors = list(tile_colors[self.downlight_zones])
880
+ self._last_downlight_colors = stored_colors
881
+
882
+ # Store for future restoration
883
+ self._stored_downlight_state = stored_colors
672
884
 
673
885
  # Create colors with brightness=0 for device
674
886
  off_colors = [
@@ -678,12 +890,10 @@ class CeilingLight(MatrixLight):
678
890
  brightness=0.0,
679
891
  kelvin=c.kelvin,
680
892
  )
681
- for c in self._stored_downlight_state
893
+ for c in stored_colors
682
894
  ]
683
895
 
684
- # Get all colors and update downlight zones
685
- all_colors = await self.get_all_tile_colors()
686
- tile_colors = all_colors[0]
896
+ # Update downlight zones and send immediately
687
897
  tile_colors[self.downlight_zones] = off_colors
688
898
  await self.set_matrix_colors(0, tile_colors, duration=int(duration * 1000))
689
899
 
@@ -694,89 +904,122 @@ class CeilingLight(MatrixLight):
694
904
  if self._state_file:
695
905
  self._save_state_to_file()
696
906
 
697
- async def _determine_uplight_brightness(self) -> HSBK:
907
+ async def _determine_uplight_brightness(
908
+ self, tile_colors: list[HSBK] | None = None
909
+ ) -> HSBK:
698
910
  """Determine uplight brightness using priority logic.
699
911
 
700
912
  Priority order:
701
- 1. Stored state (if available)
702
- 2. Infer from downlight average brightness
913
+ 1. Stored state (if available AND brightness > 0)
914
+ 2. Infer from downlight average brightness (using stored H, S, K if available)
703
915
  3. Hardcoded default (0.8)
704
916
 
917
+ Args:
918
+ tile_colors: Optional pre-fetched tile colors to avoid redundant fetch.
919
+ If None, will fetch from device.
920
+
705
921
  Returns:
706
922
  HSBK color for uplight
707
923
  """
708
- # 1. Stored state
709
- if self._stored_uplight_state is not None:
924
+ # 1. Stored state (only if brightness > 0)
925
+ if (
926
+ self._stored_uplight_state is not None
927
+ and self._stored_uplight_state.brightness > 0
928
+ ):
710
929
  return self._stored_uplight_state
711
930
 
712
- # Get current uplight color for H, S, K
713
- current_uplight = await self.get_uplight_color()
931
+ # Get current colors (use pre-fetched if available)
932
+ if tile_colors is None:
933
+ all_colors = await self.get_all_tile_colors()
934
+ tile_colors = all_colors[0]
935
+
936
+ current_uplight = tile_colors[self.uplight_zone]
937
+ downlight_colors = tile_colors[self.downlight_zones]
938
+
939
+ # Cache for is_on properties
940
+ self._last_uplight_color = current_uplight
941
+ self._last_downlight_colors = list(downlight_colors)
942
+
943
+ # Determine which color source to use for H, S, K
944
+ source_color = self._stored_uplight_state or current_uplight
714
945
 
715
946
  # 2. Infer from downlight average brightness
716
- try:
717
- downlight_colors = await self.get_downlight_colors()
718
- avg_brightness = sum(c.brightness for c in downlight_colors) / len(
719
- downlight_colors
720
- )
947
+ avg_brightness = sum(c.brightness for c in downlight_colors) / len(
948
+ downlight_colors
949
+ )
721
950
 
722
- # Only use inferred brightness if it's > 0
723
- # If all downlights are off (brightness=0), skip to default
724
- if avg_brightness > 0:
725
- return HSBK(
726
- hue=current_uplight.hue,
727
- saturation=current_uplight.saturation,
728
- brightness=avg_brightness,
729
- kelvin=current_uplight.kelvin,
730
- )
731
- except Exception: # nosec B110
732
- # If inference fails, fall through to default
733
- pass
951
+ # Only use inferred brightness if it's > 0
952
+ # If all downlights are off (brightness=0), skip to default
953
+ if avg_brightness > 0:
954
+ return HSBK(
955
+ hue=source_color.hue,
956
+ saturation=source_color.saturation,
957
+ brightness=avg_brightness,
958
+ kelvin=source_color.kelvin,
959
+ )
734
960
 
735
961
  # 3. Hardcoded default (0.8)
736
962
  return HSBK(
737
- hue=current_uplight.hue,
738
- saturation=current_uplight.saturation,
963
+ hue=source_color.hue,
964
+ saturation=source_color.saturation,
739
965
  brightness=0.8,
740
- kelvin=current_uplight.kelvin,
966
+ kelvin=source_color.kelvin,
741
967
  )
742
968
 
743
- async def _determine_downlight_brightness(self) -> list[HSBK]:
969
+ async def _determine_downlight_brightness(
970
+ self, tile_colors: list[HSBK] | None = None
971
+ ) -> list[HSBK]:
744
972
  """Determine downlight brightness using priority logic.
745
973
 
746
974
  Priority order:
747
- 1. Stored state (if available)
975
+ 1. Stored state (if available AND any brightness > 0)
748
976
  2. Infer from uplight brightness
749
977
  3. Hardcoded default (0.8)
750
978
 
979
+ Args:
980
+ tile_colors: Optional pre-fetched tile colors to avoid redundant fetch.
981
+ If None, will fetch from device.
982
+
751
983
  Returns:
752
984
  List of HSBK colors for downlight zones
753
985
  """
754
- # 1. Stored state
986
+ # 1. Stored state (only if any color has brightness > 0)
755
987
  if self._stored_downlight_state is not None:
756
- return self._stored_downlight_state
988
+ if any(c.brightness > 0 for c in self._stored_downlight_state):
989
+ return self._stored_downlight_state
757
990
 
758
- # Get current downlight colors for H, S, K
759
- current_downlight = await self.get_downlight_colors()
991
+ # Get current colors (use pre-fetched if available)
992
+ if tile_colors is None:
993
+ all_colors = await self.get_all_tile_colors()
994
+ tile_colors = all_colors[0]
760
995
 
761
- # 2. Infer from uplight brightness
762
- try:
763
- uplight_color = await self.get_uplight_color()
996
+ current_downlight = list(tile_colors[self.downlight_zones])
997
+ uplight_color = tile_colors[self.uplight_zone]
764
998
 
765
- # Only use inferred brightness if it's > 0
766
- # If uplight is off (brightness=0), skip to default
767
- if uplight_color.brightness > 0:
768
- return [
769
- HSBK(
770
- hue=c.hue,
771
- saturation=c.saturation,
772
- brightness=uplight_color.brightness,
773
- kelvin=c.kelvin,
774
- )
775
- for c in current_downlight
776
- ]
777
- except Exception: # nosec B110
778
- # If inference fails, fall through to default
779
- pass
999
+ # Cache for is_on properties
1000
+ self._last_downlight_colors = current_downlight
1001
+ self._last_uplight_color = uplight_color
1002
+
1003
+ # Prefer stored H, S, K if available, otherwise use current
1004
+ source_colors: list[HSBK] = (
1005
+ self._stored_downlight_state
1006
+ if self._stored_downlight_state is not None
1007
+ else current_downlight
1008
+ )
1009
+
1010
+ # 2. Infer from uplight brightness
1011
+ # Only use inferred brightness if it's > 0
1012
+ # If uplight is off (brightness=0), skip to default
1013
+ if uplight_color.brightness > 0:
1014
+ return [
1015
+ HSBK(
1016
+ hue=c.hue,
1017
+ saturation=c.saturation,
1018
+ brightness=uplight_color.brightness,
1019
+ kelvin=c.kelvin,
1020
+ )
1021
+ for c in source_colors
1022
+ ]
780
1023
 
781
1024
  # 3. Hardcoded default (0.8)
782
1025
  return [
@@ -786,7 +1029,7 @@ class CeilingLight(MatrixLight):
786
1029
  brightness=0.8,
787
1030
  kelvin=c.kelvin,
788
1031
  )
789
- for c in current_downlight
1032
+ for c in source_colors
790
1033
  ]
791
1034
 
792
1035
  def _is_stored_state_valid(