lifx-async 4.3.3__tar.gz → 4.3.5__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.3.3 → lifx_async-4.3.5}/PKG-INFO +1 -1
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/changelog.md +16 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/pyproject.toml +1 -1
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/devices/base.py +30 -6
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/devices/matrix.py +26 -19
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/network/connection.py +22 -11
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/test_base.py +20 -9
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/test_matrix.py +21 -7
- lifx_async-4.3.5/tests/test_network/test_concurrent_requests.py +395 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/uv.lock +1 -1
- lifx_async-4.3.3/tests/test_network/test_concurrent_requests.py +0 -199
- {lifx_async-4.3.3 → lifx_async-4.3.5}/.claude/settings.json +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/.github/dependabot.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/.github/labeler.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/.github/workflows/ci.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/.github/workflows/docs.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/.github/workflows/pr-automation.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/.gitignore +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/.pre-commit-config.yaml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/CLAUDE.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/LICENSE +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/README.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/colors.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/devices.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/effects.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/exceptions.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/high-level.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/index.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/network.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/protocol.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/api/themes.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/architecture/effects-architecture.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/architecture/overview.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/faq.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/getting-started/effects.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/getting-started/installation.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/getting-started/quickstart.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/getting-started/themes.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/index.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/migration/effect-api-changes.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/stylesheets/extra.css +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/user-guide/advanced-usage.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/user-guide/effects-custom.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/user-guide/effects-troubleshooting.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/user-guide/protocol-deep-dive.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/user-guide/themes.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/docs/user-guide/troubleshooting.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/01_simple_discovery.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/02_simple_control.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/03_waveforms.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/04_logging.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/06_pulse_effect.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/07_colorloop_effect.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/08_custom_effect.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/09_background_effect.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/10_find_specific_devices.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/11_matrix_basic.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/12_matrix_effects.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/examples/13_matrix_large.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/mkdocs.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/renovate.json +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/api.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/color.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/const.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/devices/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/devices/hev.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/devices/infrared.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/devices/light.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/devices/multizone.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/effects/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/effects/base.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/effects/colorloop.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/effects/conductor.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/effects/const.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/effects/models.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/effects/pulse.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/effects/state_manager.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/exceptions.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/network/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/network/discovery.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/network/message.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/network/transport.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/products/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/products/generator.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/products/registry.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/protocol/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/protocol/base.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/protocol/generator.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/protocol/header.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/protocol/models.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/protocol/packets.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/protocol/protocol_types.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/protocol/serializer.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/py.typed +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/theme/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/theme/canvas.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/theme/generators.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/theme/library.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/src/lifx/theme/theme.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/conftest.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_api/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_api/test_api_apply_theme.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_api/test_api_batch_errors.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_api/test_api_batch_operations.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_api/test_api_discovery.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_api/test_api_organization.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_color.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/conftest.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/test_hev.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/test_infrared.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/test_light.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/test_mac_address.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_devices/test_multizone.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_effects/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_effects/test_base.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_effects/test_capability_filtering.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_effects/test_colorloop.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_effects/test_integration.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_effects/test_models.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_effects/test_pulse.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_effects/test_state_manager.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_network/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_network/test_connection.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_network/test_discovery_devices.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_network/test_discovery_errors.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_network/test_message.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_network/test_message_advanced.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_network/test_transport.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_products/test_product_generator.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_products/test_registry.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_protocol/test_generated.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_protocol/test_header.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_protocol/test_protocol_generator.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_protocol/test_serializer.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_theme/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_theme/conftest.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_theme/test_apply_theme.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_theme/test_canvas.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_theme/test_generators.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_theme/test_library.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_theme/test_theme.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.5}/tests/test_utils.py +0 -0
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v4.3.5 (2025-11-22)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **devices**: Allow MatrixEffect without palette
|
|
10
|
+
([`fb31df5`](https://github.com/Djelibeybi/lifx-async/commit/fb31df51b1af9d8c7c2f573ec9619566b4f7393b))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## v4.3.4 (2025-11-22)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
- **network**: Exclude retry sleep time from timeout budget
|
|
18
|
+
([`312d7a7`](https://github.com/Djelibeybi/lifx-async/commit/312d7a7e2561de7d2bbf142c8a521daca31651bb))
|
|
19
|
+
|
|
20
|
+
|
|
5
21
|
## v4.3.3 (2025-11-22)
|
|
6
22
|
|
|
7
23
|
### Bug Fixes
|
|
@@ -252,6 +252,8 @@ class Device:
|
|
|
252
252
|
self.serial = serial_obj.to_string()
|
|
253
253
|
self.ip = ip
|
|
254
254
|
self.port = port
|
|
255
|
+
self._timeout = timeout
|
|
256
|
+
self._max_retries = max_retries
|
|
255
257
|
|
|
256
258
|
# Create lightweight connection handle - connection pooling is internal
|
|
257
259
|
self.connection = DeviceConnection(
|
|
@@ -304,10 +306,16 @@ class Device:
|
|
|
304
306
|
```
|
|
305
307
|
"""
|
|
306
308
|
if serial is None:
|
|
307
|
-
temp_conn = DeviceConnection(
|
|
309
|
+
temp_conn = DeviceConnection(
|
|
310
|
+
serial="000000000000",
|
|
311
|
+
ip=ip,
|
|
312
|
+
port=port,
|
|
313
|
+
timeout=timeout,
|
|
314
|
+
max_retries=max_retries,
|
|
315
|
+
)
|
|
308
316
|
try:
|
|
309
317
|
response = await temp_conn.request(
|
|
310
|
-
packets.Device.GetService(), timeout=
|
|
318
|
+
packets.Device.GetService(), timeout=timeout
|
|
311
319
|
)
|
|
312
320
|
if response and isinstance(response, packets.Device.StateService):
|
|
313
321
|
if temp_conn.serial and temp_conn.serial != "000000000000":
|
|
@@ -890,9 +898,17 @@ class Device:
|
|
|
890
898
|
|
|
891
899
|
try:
|
|
892
900
|
# Check each device for the target label
|
|
893
|
-
async for disc in discover_devices(
|
|
901
|
+
async for disc in discover_devices(
|
|
902
|
+
timeout=discover_timeout,
|
|
903
|
+
device_timeout=self._timeout,
|
|
904
|
+
max_retries=self._max_retries,
|
|
905
|
+
):
|
|
894
906
|
temp_conn = DeviceConnection(
|
|
895
|
-
serial=disc.serial,
|
|
907
|
+
serial=disc.serial,
|
|
908
|
+
ip=disc.ip,
|
|
909
|
+
port=disc.port,
|
|
910
|
+
timeout=self._timeout,
|
|
911
|
+
max_retries=self._max_retries,
|
|
896
912
|
)
|
|
897
913
|
|
|
898
914
|
try:
|
|
@@ -1063,9 +1079,17 @@ class Device:
|
|
|
1063
1079
|
|
|
1064
1080
|
try:
|
|
1065
1081
|
# Check each device for the target label
|
|
1066
|
-
async for disc in discover_devices(
|
|
1082
|
+
async for disc in discover_devices(
|
|
1083
|
+
timeout=discover_timeout,
|
|
1084
|
+
device_timeout=self._timeout,
|
|
1085
|
+
max_retries=self._max_retries,
|
|
1086
|
+
):
|
|
1067
1087
|
temp_conn = DeviceConnection(
|
|
1068
|
-
serial=disc.serial,
|
|
1088
|
+
serial=disc.serial,
|
|
1089
|
+
ip=disc.ip,
|
|
1090
|
+
port=disc.port,
|
|
1091
|
+
timeout=self._timeout,
|
|
1092
|
+
max_retries=self._max_retries,
|
|
1069
1093
|
)
|
|
1070
1094
|
|
|
1071
1095
|
try:
|
|
@@ -171,11 +171,6 @@ class MatrixEffect:
|
|
|
171
171
|
|
|
172
172
|
def __post_init__(self) -> None:
|
|
173
173
|
"""Initialize defaults and validate fields."""
|
|
174
|
-
# Initialize default palette if not provided
|
|
175
|
-
if self.palette is None:
|
|
176
|
-
# Default palette: single white color
|
|
177
|
-
self.palette = [HSBK(0, 0, 1.0, 3500)]
|
|
178
|
-
|
|
179
174
|
# Validate all fields
|
|
180
175
|
# Speed can be 0 only when effect is OFF
|
|
181
176
|
if self.effect_type != FirmwareEffect.OFF:
|
|
@@ -184,7 +179,11 @@ class MatrixEffect:
|
|
|
184
179
|
raise ValueError(f"Effect speed must be non-negative, got {self.speed}")
|
|
185
180
|
|
|
186
181
|
self._validate_duration(self.duration)
|
|
187
|
-
|
|
182
|
+
|
|
183
|
+
# Only validate palette if provided
|
|
184
|
+
if self.palette is not None:
|
|
185
|
+
self._validate_palette(self.palette)
|
|
186
|
+
|
|
188
187
|
self._validate_saturation(self.cloud_saturation_min, "cloud_saturation_min")
|
|
189
188
|
self._validate_saturation(self.cloud_saturation_max, "cloud_saturation_max")
|
|
190
189
|
|
|
@@ -762,7 +761,7 @@ class MatrixLight(Light):
|
|
|
762
761
|
effect_type: Type of effect (OFF, MORPH, FLAME, SKY)
|
|
763
762
|
speed: Effect speed in seconds (default: 3)
|
|
764
763
|
duration: Total effect duration in nanoseconds (0 for infinite)
|
|
765
|
-
palette: Color palette for the effect (max 16 colors)
|
|
764
|
+
palette: Color palette for the effect (max 16 colors, None for no palette)
|
|
766
765
|
sky_type: Sky effect type (SUNRISE, SUNSET, CLOUDS)
|
|
767
766
|
cloud_saturation_min: Minimum cloud saturation (0-255, for CLOUDS)
|
|
768
767
|
cloud_saturation_max: Maximum cloud saturation (0-255, for CLOUDS)
|
|
@@ -780,6 +779,12 @@ class MatrixLight(Light):
|
|
|
780
779
|
... speed=5.0,
|
|
781
780
|
... palette=rainbow,
|
|
782
781
|
... )
|
|
782
|
+
|
|
783
|
+
>>> # Set effect without a palette
|
|
784
|
+
>>> await matrix.set_effect(
|
|
785
|
+
... effect_type=FirmwareEffect.FLAME,
|
|
786
|
+
... speed=3.0,
|
|
787
|
+
... )
|
|
783
788
|
"""
|
|
784
789
|
_LOGGER.debug(
|
|
785
790
|
"Setting matrix effect %s (speed=%d) for %s",
|
|
@@ -801,20 +806,22 @@ class MatrixLight(Light):
|
|
|
801
806
|
)
|
|
802
807
|
|
|
803
808
|
# Convert to protocol format
|
|
804
|
-
# Note: palette is guaranteed to be non-None by MatrixEffect.__post_init__
|
|
805
|
-
palette = effect.palette if effect.palette is not None else []
|
|
806
809
|
proto_palette = []
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
810
|
+
palette_count = 0
|
|
811
|
+
|
|
812
|
+
if effect.palette is not None:
|
|
813
|
+
palette_count = len(effect.palette)
|
|
814
|
+
for color in effect.palette:
|
|
815
|
+
proto_palette.append(
|
|
816
|
+
LightHsbk(
|
|
817
|
+
hue=int(color.hue / 360 * 65535),
|
|
818
|
+
saturation=int(color.saturation * 65535),
|
|
819
|
+
brightness=int(color.brightness * 65535),
|
|
820
|
+
kelvin=color.kelvin,
|
|
821
|
+
)
|
|
814
822
|
)
|
|
815
|
-
)
|
|
816
823
|
|
|
817
|
-
# Pad palette to 16 colors
|
|
824
|
+
# Pad palette to 16 colors (protocol requirement)
|
|
818
825
|
while len(proto_palette) < 16:
|
|
819
826
|
proto_palette.append(LightHsbk(0, 0, 0, 3500))
|
|
820
827
|
|
|
@@ -828,7 +835,7 @@ class MatrixLight(Light):
|
|
|
828
835
|
cloud_saturation_min=effect.cloud_saturation_min,
|
|
829
836
|
cloud_saturation_max=effect.cloud_saturation_max,
|
|
830
837
|
),
|
|
831
|
-
palette_count=
|
|
838
|
+
palette_count=palette_count,
|
|
832
839
|
palette=proto_palette,
|
|
833
840
|
)
|
|
834
841
|
|
|
@@ -467,13 +467,10 @@ class DeviceConnection:
|
|
|
467
467
|
correlation_keys: list[tuple[int, int, str]] = []
|
|
468
468
|
|
|
469
469
|
# Calculate per-attempt timeouts with exponential backoff
|
|
470
|
-
#
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
else:
|
|
475
|
-
# Only one attempt total, use entire timeout
|
|
476
|
-
attempt_timeout = timeout
|
|
470
|
+
# Use proper exponential backoff distribution: timeout / (2^(n+1) - 1)
|
|
471
|
+
# This ensures total of all attempt timeouts equals the overall timeout budget
|
|
472
|
+
total_weight = (2 ** (max_retries + 1)) - 1
|
|
473
|
+
base_timeout = timeout / total_weight
|
|
477
474
|
|
|
478
475
|
# Idle timeout for multi-response protocols
|
|
479
476
|
# Stop streaming if no responses for this long after first response
|
|
@@ -482,14 +479,17 @@ class DeviceConnection:
|
|
|
482
479
|
last_error: Exception | None = None
|
|
483
480
|
has_yielded = False
|
|
484
481
|
overall_start = time.monotonic()
|
|
482
|
+
total_sleep_time = 0.0 # Track sleep time to exclude from timeout budget
|
|
485
483
|
|
|
486
484
|
try:
|
|
487
485
|
for attempt in range(max_retries + 1):
|
|
488
486
|
# Calculate current attempt timeout with exponential backoff
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
487
|
+
# Exclude sleep time from elapsed time to preserve timeout budget
|
|
488
|
+
elapsed_response_time = (
|
|
489
|
+
time.monotonic() - overall_start - total_sleep_time
|
|
492
490
|
)
|
|
491
|
+
ideal_timeout = base_timeout * (2**attempt)
|
|
492
|
+
current_timeout = min(ideal_timeout, timeout - elapsed_response_time)
|
|
493
493
|
|
|
494
494
|
# Check if we've exceeded overall timeout budget
|
|
495
495
|
if current_timeout <= 0:
|
|
@@ -616,6 +616,9 @@ class DeviceConnection:
|
|
|
616
616
|
# Sleep with jitter before retry
|
|
617
617
|
sleep_time = self._calculate_retry_sleep_with_jitter(attempt)
|
|
618
618
|
await asyncio.sleep(sleep_time)
|
|
619
|
+
total_sleep_time += (
|
|
620
|
+
sleep_time # Track sleep to exclude from timeout
|
|
621
|
+
)
|
|
619
622
|
continue
|
|
620
623
|
else:
|
|
621
624
|
# All retries exhausted
|
|
@@ -673,9 +676,14 @@ class DeviceConnection:
|
|
|
673
676
|
base_timeout = timeout / total_weight
|
|
674
677
|
|
|
675
678
|
last_error: Exception | None = None
|
|
679
|
+
total_sleep_time = 0.0 # Track sleep time to exclude from timeout budget
|
|
680
|
+
overall_start = time.monotonic()
|
|
676
681
|
|
|
677
682
|
for attempt in range(max_retries + 1):
|
|
678
|
-
|
|
683
|
+
# Calculate timeout with budget remaining after excluding sleep time
|
|
684
|
+
elapsed_response_time = time.monotonic() - overall_start - total_sleep_time
|
|
685
|
+
ideal_timeout = base_timeout * (2**attempt)
|
|
686
|
+
current_timeout = min(ideal_timeout, timeout - elapsed_response_time)
|
|
679
687
|
sequence = attempt
|
|
680
688
|
|
|
681
689
|
# Correlation key: (source, sequence, serial)
|
|
@@ -737,6 +745,9 @@ class DeviceConnection:
|
|
|
737
745
|
# Sleep with jitter before retry
|
|
738
746
|
sleep_time = self._calculate_retry_sleep_with_jitter(attempt)
|
|
739
747
|
await asyncio.sleep(sleep_time)
|
|
748
|
+
total_sleep_time += (
|
|
749
|
+
sleep_time # Track sleep to exclude from timeout
|
|
750
|
+
)
|
|
740
751
|
continue
|
|
741
752
|
else:
|
|
742
753
|
break
|
|
@@ -527,7 +527,7 @@ class TestLocationAndGroupManagement:
|
|
|
527
527
|
mock_discovered_conn.request = AsyncMock(return_value=mock_state_location)
|
|
528
528
|
|
|
529
529
|
# Create async generator mock for discover_devices
|
|
530
|
-
async def mock_discover_gen(timeout: float = 5.0):
|
|
530
|
+
async def mock_discover_gen(timeout: float = 5.0, **kwargs):
|
|
531
531
|
for disc in discovered_devices:
|
|
532
532
|
yield disc
|
|
533
533
|
|
|
@@ -537,8 +537,10 @@ class TestLocationAndGroupManagement:
|
|
|
537
537
|
),
|
|
538
538
|
patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
|
|
539
539
|
):
|
|
540
|
-
#
|
|
541
|
-
mock_conn_class.
|
|
540
|
+
# Only one DeviceConnection created for discovered device
|
|
541
|
+
mock_conn_class.return_value = mock_discovered_conn
|
|
542
|
+
# Add async close method to mock
|
|
543
|
+
mock_discovered_conn.close = AsyncMock()
|
|
542
544
|
|
|
543
545
|
await device.set_location(label)
|
|
544
546
|
|
|
@@ -581,7 +583,7 @@ class TestLocationAndGroupManagement:
|
|
|
581
583
|
mock_discovered_conn.request = AsyncMock(return_value=mock_state_location)
|
|
582
584
|
|
|
583
585
|
# Create async generator mock for discover_devices
|
|
584
|
-
async def mock_discover_gen(timeout: float = 5.0):
|
|
586
|
+
async def mock_discover_gen(timeout: float = 5.0, **kwargs):
|
|
585
587
|
for disc in discovered_devices:
|
|
586
588
|
yield disc
|
|
587
589
|
|
|
@@ -591,7 +593,10 @@ class TestLocationAndGroupManagement:
|
|
|
591
593
|
),
|
|
592
594
|
patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
|
|
593
595
|
):
|
|
594
|
-
|
|
596
|
+
# Only one DeviceConnection created for discovered device
|
|
597
|
+
mock_conn_class.return_value = mock_discovered_conn
|
|
598
|
+
# Add async close method to mock
|
|
599
|
+
mock_discovered_conn.close = AsyncMock()
|
|
595
600
|
|
|
596
601
|
await device.set_location(label)
|
|
597
602
|
|
|
@@ -629,7 +634,7 @@ class TestLocationAndGroupManagement:
|
|
|
629
634
|
mock_discovered_conn.request = AsyncMock(return_value=mock_state_group)
|
|
630
635
|
|
|
631
636
|
# Create async generator mock for discover_devices
|
|
632
|
-
async def mock_discover_gen(timeout: float = 5.0):
|
|
637
|
+
async def mock_discover_gen(timeout: float = 5.0, **kwargs):
|
|
633
638
|
for disc in discovered_devices:
|
|
634
639
|
yield disc
|
|
635
640
|
|
|
@@ -639,7 +644,10 @@ class TestLocationAndGroupManagement:
|
|
|
639
644
|
),
|
|
640
645
|
patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
|
|
641
646
|
):
|
|
642
|
-
|
|
647
|
+
# Only one DeviceConnection created for discovered device
|
|
648
|
+
mock_conn_class.return_value = mock_discovered_conn
|
|
649
|
+
# Add async close method to mock
|
|
650
|
+
mock_discovered_conn.close = AsyncMock()
|
|
643
651
|
|
|
644
652
|
await device.set_group(label)
|
|
645
653
|
|
|
@@ -682,7 +690,7 @@ class TestLocationAndGroupManagement:
|
|
|
682
690
|
mock_discovered_conn.request = AsyncMock(return_value=mock_state_group)
|
|
683
691
|
|
|
684
692
|
# Create async generator mock for discover_devices
|
|
685
|
-
async def mock_discover_gen(timeout: float = 5.0):
|
|
693
|
+
async def mock_discover_gen(timeout: float = 5.0, **kwargs):
|
|
686
694
|
for disc in discovered_devices:
|
|
687
695
|
yield disc
|
|
688
696
|
|
|
@@ -692,7 +700,10 @@ class TestLocationAndGroupManagement:
|
|
|
692
700
|
),
|
|
693
701
|
patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
|
|
694
702
|
):
|
|
695
|
-
|
|
703
|
+
# Only one DeviceConnection created for discovered device
|
|
704
|
+
mock_conn_class.return_value = mock_discovered_conn
|
|
705
|
+
# Add async close method to mock
|
|
706
|
+
mock_discovered_conn.close = AsyncMock()
|
|
696
707
|
|
|
697
708
|
await device.set_group(label)
|
|
698
709
|
|
|
@@ -177,8 +177,9 @@ class TestMatrixLight:
|
|
|
177
177
|
assert isinstance(effect.effect_type, FirmwareEffect)
|
|
178
178
|
assert effect.speed >= 0
|
|
179
179
|
assert effect.duration >= 0
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
# Palette can be None if palette_count is 0
|
|
181
|
+
if effect.palette is not None:
|
|
182
|
+
assert len(effect.palette) > 0
|
|
182
183
|
|
|
183
184
|
async def test_tile_effect_cached_property(self, emulator_devices) -> None:
|
|
184
185
|
"""Test tile_effect property caching."""
|
|
@@ -321,6 +322,21 @@ class TestMatrixLight:
|
|
|
321
322
|
effect = await matrix.get_effect()
|
|
322
323
|
assert effect.effect_type == FirmwareEffect.OFF
|
|
323
324
|
|
|
325
|
+
async def test_set_effect_without_palette(self, emulator_devices) -> None:
|
|
326
|
+
"""Test setting effect without a palette (palette_count=0)."""
|
|
327
|
+
matrix = emulator_devices[6]
|
|
328
|
+
async with matrix:
|
|
329
|
+
# Set effect without palette - should send palette_count=0
|
|
330
|
+
await matrix.set_effect(
|
|
331
|
+
effect_type=FirmwareEffect.MORPH,
|
|
332
|
+
speed=3000,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Verify effect was set
|
|
336
|
+
effect = await matrix.get_effect()
|
|
337
|
+
assert effect.effect_type == FirmwareEffect.MORPH
|
|
338
|
+
assert effect.palette is None
|
|
339
|
+
|
|
324
340
|
async def test_get64_large_tile(self, ceiling_device) -> None:
|
|
325
341
|
"""Test getting colors from 16x8 tile (128 zones) with default parameters.
|
|
326
342
|
|
|
@@ -553,15 +569,13 @@ class TestMatrixEffect:
|
|
|
553
569
|
assert effect.palette is not None
|
|
554
570
|
assert len(effect.palette) == 1
|
|
555
571
|
|
|
556
|
-
def
|
|
557
|
-
"""Test that
|
|
572
|
+
def test_effect_none_palette(self) -> None:
|
|
573
|
+
"""Test that palette can be None (no palette specified)."""
|
|
558
574
|
effect = MatrixEffect(
|
|
559
575
|
effect_type=FirmwareEffect.MORPH,
|
|
560
576
|
speed=3000,
|
|
561
577
|
)
|
|
562
|
-
assert effect.palette is
|
|
563
|
-
assert len(effect.palette) == 1
|
|
564
|
-
assert isinstance(effect.palette[0], HSBK)
|
|
578
|
+
assert effect.palette is None
|
|
565
579
|
|
|
566
580
|
def test_effect_validation_negative_speed(self) -> None:
|
|
567
581
|
"""Test that negative speed raises error."""
|