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.
- {lifx_async-4.7.2 → lifx_async-4.7.3}/PKG-INFO +1 -1
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/changelog.md +8 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/pyproject.toml +1 -1
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/ceiling.py +59 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_ceiling.py +236 -1
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_integration.py +56 -9
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_connection.py +4 -2
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_canvas.py +4 -2
- {lifx_async-4.7.2 → lifx_async-4.7.3}/uv.lock +1 -1
- {lifx_async-4.7.2 → lifx_async-4.7.3}/.claude/settings.json +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/dependabot.yml +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/labeler.yml +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/workflows/ci.yml +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/workflows/docs.yml +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/.github/workflows/pr-automation.yml +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/.gitignore +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/.pre-commit-config.yaml +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/CLAUDE.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/LICENSE +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/README.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/context7.json +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/colors.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/devices.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/effects.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/exceptions.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/high-level.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/index.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/network.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/protocol.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/api/themes.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/architecture/effects-architecture.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/architecture/overview.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/faq.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/getting-started/effects.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/getting-started/installation.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/getting-started/quickstart.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/getting-started/themes.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/index.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/migration/effect-api-changes.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/stylesheets/extra.css +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/advanced-usage.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/ceiling-lights.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/effects-custom.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/effects-troubleshooting.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/protocol-deep-dive.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/themes.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/docs/user-guide/troubleshooting.md +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/01_simple_discovery.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/02_simple_control.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/03_waveforms.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/04_logging.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/06_pulse_effect.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/07_colorloop_effect.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/08_custom_effect.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/09_background_effect.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/10_find_specific_devices.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/11_matrix_basic.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/12_matrix_effects.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/examples/13_matrix_large.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/mkdocs.yml +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/renovate.json +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/api.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/color.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/const.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/base.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/hev.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/infrared.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/light.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/matrix.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/devices/multizone.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/base.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/colorloop.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/conductor.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/const.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/models.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/pulse.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/effects/state_manager.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/exceptions.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/connection.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/discovery.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/message.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/network/transport.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/products/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/products/generator.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/products/quirks.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/products/registry.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/base.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/generator.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/header.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/models.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/packets.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/protocol_types.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/protocol/serializer.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/py.typed +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/canvas.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/generators.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/library.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/src/lifx/theme/theme.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/conftest.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_apply_theme.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_batch_errors.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_batch_operations.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_discovery.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_api/test_api_organization.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_color.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/conftest.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_base.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_hev.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_infrared.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_light.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_mac_address.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_matrix.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_multizone.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_ceiling.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_hev.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_infrared.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_light.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_management.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_matrix.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_devices/test_state_multizone.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_base.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_capability_filtering.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_colorloop.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_models.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_pulse.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_effects/test_state_manager.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_concurrent_requests.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_discovery_devices.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_discovery_errors.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_message.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_message_advanced.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_network/test_transport.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_products/test_product_generator.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_products/test_registry.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_protocol/test_generated.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_protocol/test_header.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_protocol/test_protocol_generator.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_protocol/test_serializer.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/__init__.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/conftest.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_apply_theme.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_generators.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_library.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_theme/test_theme.py +0 -0
- {lifx_async-4.7.2 → lifx_async-4.7.3}/tests/test_utils.py +0 -0
|
@@ -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
|
|
@@ -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
|
-
#
|
|
120
|
-
mock_light.set_waveform.
|
|
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
|
-
#
|
|
310
|
-
#
|
|
311
|
-
await
|
|
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
|
-
#
|
|
173
|
-
|
|
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[(
|
|
127
|
-
canvas[(
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|