lifx-async 4.3.4__tar.gz → 4.3.6__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 (144) hide show
  1. {lifx_async-4.3.4 → lifx_async-4.3.6}/PKG-INFO +1 -1
  2. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/changelog.md +16 -0
  3. {lifx_async-4.3.4 → lifx_async-4.3.6}/pyproject.toml +1 -1
  4. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/devices/matrix.py +26 -19
  5. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/network/connection.py +35 -34
  6. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/conftest.py +56 -0
  7. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/test_matrix.py +21 -7
  8. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_network/test_connection.py +51 -3
  9. {lifx_async-4.3.4 → lifx_async-4.3.6}/uv.lock +1 -1
  10. {lifx_async-4.3.4 → lifx_async-4.3.6}/.claude/settings.json +0 -0
  11. {lifx_async-4.3.4 → lifx_async-4.3.6}/.github/dependabot.yml +0 -0
  12. {lifx_async-4.3.4 → lifx_async-4.3.6}/.github/labeler.yml +0 -0
  13. {lifx_async-4.3.4 → lifx_async-4.3.6}/.github/workflows/ci.yml +0 -0
  14. {lifx_async-4.3.4 → lifx_async-4.3.6}/.github/workflows/docs.yml +0 -0
  15. {lifx_async-4.3.4 → lifx_async-4.3.6}/.github/workflows/pr-automation.yml +0 -0
  16. {lifx_async-4.3.4 → lifx_async-4.3.6}/.gitignore +0 -0
  17. {lifx_async-4.3.4 → lifx_async-4.3.6}/.pre-commit-config.yaml +0 -0
  18. {lifx_async-4.3.4 → lifx_async-4.3.6}/CLAUDE.md +0 -0
  19. {lifx_async-4.3.4 → lifx_async-4.3.6}/LICENSE +0 -0
  20. {lifx_async-4.3.4 → lifx_async-4.3.6}/README.md +0 -0
  21. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/colors.md +0 -0
  22. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/devices.md +0 -0
  23. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/effects.md +0 -0
  24. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/exceptions.md +0 -0
  25. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/high-level.md +0 -0
  26. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/index.md +0 -0
  27. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/network.md +0 -0
  28. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/protocol.md +0 -0
  29. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/api/themes.md +0 -0
  30. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/architecture/effects-architecture.md +0 -0
  31. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/architecture/overview.md +0 -0
  32. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/faq.md +0 -0
  33. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/getting-started/effects.md +0 -0
  34. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/getting-started/installation.md +0 -0
  35. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/getting-started/quickstart.md +0 -0
  36. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/getting-started/themes.md +0 -0
  37. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/index.md +0 -0
  38. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/migration/effect-api-changes.md +0 -0
  39. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/stylesheets/extra.css +0 -0
  40. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/user-guide/advanced-usage.md +0 -0
  41. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/user-guide/effects-custom.md +0 -0
  42. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/user-guide/effects-troubleshooting.md +0 -0
  43. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/user-guide/protocol-deep-dive.md +0 -0
  44. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/user-guide/themes.md +0 -0
  45. {lifx_async-4.3.4 → lifx_async-4.3.6}/docs/user-guide/troubleshooting.md +0 -0
  46. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/01_simple_discovery.py +0 -0
  47. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/02_simple_control.py +0 -0
  48. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/03_waveforms.py +0 -0
  49. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/04_logging.py +0 -0
  50. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/06_pulse_effect.py +0 -0
  51. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/07_colorloop_effect.py +0 -0
  52. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/08_custom_effect.py +0 -0
  53. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/09_background_effect.py +0 -0
  54. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/10_find_specific_devices.py +0 -0
  55. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/11_matrix_basic.py +0 -0
  56. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/12_matrix_effects.py +0 -0
  57. {lifx_async-4.3.4 → lifx_async-4.3.6}/examples/13_matrix_large.py +0 -0
  58. {lifx_async-4.3.4 → lifx_async-4.3.6}/mkdocs.yml +0 -0
  59. {lifx_async-4.3.4 → lifx_async-4.3.6}/renovate.json +0 -0
  60. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/__init__.py +0 -0
  61. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/api.py +0 -0
  62. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/color.py +0 -0
  63. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/const.py +0 -0
  64. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/devices/__init__.py +0 -0
  65. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/devices/base.py +0 -0
  66. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/devices/hev.py +0 -0
  67. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/devices/infrared.py +0 -0
  68. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/devices/light.py +0 -0
  69. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/devices/multizone.py +0 -0
  70. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/effects/__init__.py +0 -0
  71. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/effects/base.py +0 -0
  72. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/effects/colorloop.py +0 -0
  73. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/effects/conductor.py +0 -0
  74. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/effects/const.py +0 -0
  75. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/effects/models.py +0 -0
  76. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/effects/pulse.py +0 -0
  77. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/effects/state_manager.py +0 -0
  78. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/exceptions.py +0 -0
  79. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/network/__init__.py +0 -0
  80. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/network/discovery.py +0 -0
  81. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/network/message.py +0 -0
  82. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/network/transport.py +0 -0
  83. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/products/__init__.py +0 -0
  84. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/products/generator.py +0 -0
  85. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/products/registry.py +0 -0
  86. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/protocol/__init__.py +0 -0
  87. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/protocol/base.py +0 -0
  88. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/protocol/generator.py +0 -0
  89. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/protocol/header.py +0 -0
  90. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/protocol/models.py +0 -0
  91. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/protocol/packets.py +0 -0
  92. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/protocol/protocol_types.py +0 -0
  93. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/protocol/serializer.py +0 -0
  94. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/py.typed +0 -0
  95. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/theme/__init__.py +0 -0
  96. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/theme/canvas.py +0 -0
  97. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/theme/generators.py +0 -0
  98. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/theme/library.py +0 -0
  99. {lifx_async-4.3.4 → lifx_async-4.3.6}/src/lifx/theme/theme.py +0 -0
  100. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/__init__.py +0 -0
  101. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_api/__init__.py +0 -0
  102. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_api/test_api_apply_theme.py +0 -0
  103. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_api/test_api_batch_errors.py +0 -0
  104. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_api/test_api_batch_operations.py +0 -0
  105. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_api/test_api_discovery.py +0 -0
  106. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_api/test_api_organization.py +0 -0
  107. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_color.py +0 -0
  108. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/__init__.py +0 -0
  109. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/conftest.py +0 -0
  110. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/test_base.py +0 -0
  111. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/test_hev.py +0 -0
  112. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/test_infrared.py +0 -0
  113. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/test_light.py +0 -0
  114. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/test_mac_address.py +0 -0
  115. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_devices/test_multizone.py +0 -0
  116. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_effects/__init__.py +0 -0
  117. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_effects/test_base.py +0 -0
  118. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_effects/test_capability_filtering.py +0 -0
  119. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_effects/test_colorloop.py +0 -0
  120. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_effects/test_integration.py +0 -0
  121. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_effects/test_models.py +0 -0
  122. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_effects/test_pulse.py +0 -0
  123. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_effects/test_state_manager.py +0 -0
  124. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_network/__init__.py +0 -0
  125. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_network/test_concurrent_requests.py +0 -0
  126. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_network/test_discovery_devices.py +0 -0
  127. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_network/test_discovery_errors.py +0 -0
  128. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_network/test_message.py +0 -0
  129. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_network/test_message_advanced.py +0 -0
  130. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_network/test_transport.py +0 -0
  131. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_products/test_product_generator.py +0 -0
  132. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_products/test_registry.py +0 -0
  133. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_protocol/test_generated.py +0 -0
  134. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_protocol/test_header.py +0 -0
  135. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_protocol/test_protocol_generator.py +0 -0
  136. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_protocol/test_serializer.py +0 -0
  137. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_theme/__init__.py +0 -0
  138. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_theme/conftest.py +0 -0
  139. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_theme/test_apply_theme.py +0 -0
  140. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_theme/test_canvas.py +0 -0
  141. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_theme/test_generators.py +0 -0
  142. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_theme/test_library.py +0 -0
  143. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_theme/test_theme.py +0 -0
  144. {lifx_async-4.3.4 → lifx_async-4.3.6}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 4.3.4
3
+ Version: 4.3.6
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.3.6 (2025-11-25)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **network**: Return StateUnhandled packets instead of raising exception
10
+ ([`f27e848`](https://github.com/Djelibeybi/lifx-async/commit/f27e84849656a84e7e120d66d1dba7bbabe18ed5))
11
+
12
+
13
+ ## v4.3.5 (2025-11-22)
14
+
15
+ ### Bug Fixes
16
+
17
+ - **devices**: Allow MatrixEffect without palette
18
+ ([`fb31df5`](https://github.com/Djelibeybi/lifx-async/commit/fb31df51b1af9d8c7c2f573ec9619566b4f7393b))
19
+
20
+
5
21
  ## v4.3.4 (2025-11-22)
6
22
 
7
23
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-async"
3
- version = "4.3.4"
3
+ version = "4.3.6"
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"
@@ -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
- self._validate_palette(self.palette)
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
- for color in palette:
808
- proto_palette.append(
809
- LightHsbk(
810
- hue=int(color.hue / 360 * 65535),
811
- saturation=int(color.saturation * 65535),
812
- brightness=int(color.brightness * 65535),
813
- kelvin=color.kelvin,
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=len(palette),
838
+ palette_count=palette_count,
832
839
  palette=proto_palette,
833
840
  )
834
841
 
@@ -22,7 +22,6 @@ from lifx.exceptions import (
22
22
  LifxConnectionError,
23
23
  LifxProtocolError,
24
24
  LifxTimeoutError,
25
- LifxUnsupportedCommandError,
26
25
  )
27
26
  from lifx.network.message import create_message, parse_message
28
27
  from lifx.network.transport import UdpTransport
@@ -443,13 +442,12 @@ class DeviceConnection:
443
442
  LifxConnectionError: If connection is not open
444
443
  LifxProtocolError: If response correlation validation fails
445
444
  LifxTimeoutError: If no response after all retries
446
- LifxUnsupportedCommandError: If device doesn't support command
447
445
  """
448
446
  if not self._is_open or self._transport is None:
449
- raise LifxConnectionError("Connection not open")
447
+ raise LifxConnectionError("Connection not open") # pragma: no cover
450
448
 
451
449
  if timeout is None:
452
- timeout = self.timeout
450
+ timeout = self.timeout # pragma: no cover
453
451
 
454
452
  if max_retries is None:
455
453
  max_retries = self.max_retries
@@ -597,12 +595,6 @@ class DeviceConnection:
597
595
  f"got {header.sequence}, max expected {max_expected}"
598
596
  )
599
597
 
600
- # Check for StateUnhandled
601
- if header.pkt_type == _STATE_UNHANDLED_PKT_TYPE:
602
- raise LifxUnsupportedCommandError(
603
- "Request unsupported by device: received StateUnhandled"
604
- )
605
-
606
598
  # Yield response (can be from any retry attempt)
607
599
  has_yielded = True
608
600
  last_response_time = time.monotonic()
@@ -640,7 +632,7 @@ class DeviceConnection:
640
632
  request: Any,
641
633
  timeout: float | None = None,
642
634
  max_retries: int | None = None,
643
- ) -> AsyncGenerator[None, None]:
635
+ ) -> AsyncGenerator[bool, None]:
644
636
  """Internal implementation of request_ack_stream with retry logic.
645
637
 
646
638
  This is an async generator that sends a request requiring acknowledgement
@@ -652,18 +644,17 @@ class DeviceConnection:
652
644
  max_retries: Maximum retries
653
645
 
654
646
  Yields:
655
- None (single yield on successful ack)
647
+ True for successful ACK, False if device returned StateUnhandled
656
648
 
657
649
  Raises:
658
650
  LifxConnectionError: If connection is not open
659
651
  LifxTimeoutError: If no ack after all retries
660
- LifxUnsupportedCommandError: If device doesn't support command
661
652
  """
662
653
  if not self._is_open or self._transport is None:
663
- raise LifxConnectionError("Connection not open")
654
+ raise LifxConnectionError("Connection not open") # pragma: no cover
664
655
 
665
656
  if timeout is None:
666
- timeout = self.timeout
657
+ timeout = self.timeout # pragma: no cover
667
658
 
668
659
  if max_retries is None:
669
660
  max_retries = self.max_retries
@@ -729,14 +720,13 @@ class DeviceConnection:
729
720
  f"{Serial.from_protocol(header.target).to_string()})"
730
721
  )
731
722
 
732
- # Check for StateUnhandled
723
+ # Check for StateUnhandled - return False to indicate unsupported
733
724
  if header.pkt_type == _STATE_UNHANDLED_PKT_TYPE:
734
- raise LifxUnsupportedCommandError(
735
- "Request unsupported by device: received StateUnhandled"
736
- )
725
+ yield False
726
+ return
737
727
 
738
728
  # ACK received successfully
739
- yield
729
+ yield True
740
730
  return
741
731
 
742
732
  except TimeoutError as e:
@@ -791,14 +781,14 @@ class DeviceConnection:
791
781
  timeout: Request timeout in seconds
792
782
 
793
783
  Yields:
794
- Unpacked response packet instances
795
- For SET packets: yields True once (acknowledgement)
784
+ Unpacked response packet instances (including StateUnhandled if device
785
+ doesn't support the command)
786
+ For SET packets: yields True (acknowledgement) or False (StateUnhandled)
796
787
 
797
788
  Raises:
798
789
  LifxTimeoutError: If request times out
799
790
  LifxProtocolError: If response invalid
800
791
  LifxConnectionError: If connection fails
801
- LifxUnsupportedCommandError: If command not supported
802
792
 
803
793
  Example:
804
794
  ```python
@@ -808,11 +798,16 @@ class DeviceConnection:
808
798
  label = state.label # Already decoded to string
809
799
  break
810
800
 
811
- # SET request yields True (acknowledgement)
812
- async for _ in conn.request_stream(
801
+ # SET request yields True (acknowledgement) or False (StateUnhandled)
802
+ async for result in conn.request_stream(
813
803
  packets.Light.SetColor(color=hsbk, duration=1000)
814
804
  ):
815
- # Acknowledgement received
805
+ if result:
806
+ # Acknowledgement received
807
+ pass
808
+ else:
809
+ # Device doesn't support this command
810
+ pass
816
811
  break
817
812
 
818
813
  # Multi-response GET - stream all responses
@@ -875,9 +870,12 @@ class DeviceConnection:
875
870
 
876
871
  elif packet_kind == "SET":
877
872
  # Request acknowledgement
878
- async for _ in self._request_ack_stream_impl(packet, timeout=timeout):
873
+ async for ack_result in self._request_ack_stream_impl(
874
+ packet, timeout=timeout
875
+ ):
879
876
  # Log the request/ack cycle
880
877
  request_values = packet.as_dict
878
+ reply_packet = "Acknowledgement" if ack_result else "StateUnhandled"
881
879
  _LOGGER.debug(
882
880
  {
883
881
  "class": "DeviceConnection",
@@ -887,7 +885,7 @@ class DeviceConnection:
887
885
  "values": request_values,
888
886
  },
889
887
  "reply": {
890
- "packet": "Acknowledgement",
888
+ "packet": reply_packet,
891
889
  "values": {},
892
890
  },
893
891
  "serial": self.serial,
@@ -895,7 +893,7 @@ class DeviceConnection:
895
893
  }
896
894
  )
897
895
 
898
- yield True
896
+ yield ack_result
899
897
  return
900
898
 
901
899
  else:
@@ -934,7 +932,7 @@ class DeviceConnection:
934
932
  yield response_packet
935
933
  return
936
934
  else:
937
- raise LifxUnsupportedCommandError(
935
+ raise LifxProtocolError(
938
936
  f"Cannot auto-handle packet kind: {packet_kind}"
939
937
  )
940
938
  else:
@@ -961,14 +959,14 @@ class DeviceConnection:
961
959
  timeout: Request timeout in seconds
962
960
 
963
961
  Returns:
964
- Single unpacked response packet
965
- True for SET acknowledgement
962
+ Single unpacked response packet (including StateUnhandled if device
963
+ doesn't support the command)
964
+ For SET packets: True (acknowledgement) or False (StateUnhandled)
966
965
 
967
966
  Raises:
968
967
  LifxTimeoutError: If no response within timeout
969
968
  LifxProtocolError: If response invalid
970
969
  LifxConnectionError: If connection fails
971
- LifxUnsupportedCommandError: If command not supported
972
970
 
973
971
  Example:
974
972
  ```python
@@ -977,10 +975,13 @@ class DeviceConnection:
977
975
  color = HSBK.from_protocol(state.color)
978
976
  label = state.label # Already decoded to string
979
977
 
980
- # SET request returns True
978
+ # SET request returns True or False
981
979
  success = await conn.request(
982
980
  packets.Light.SetColor(color=hsbk, duration=1000)
983
981
  )
982
+ if not success:
983
+ # Device doesn't support this command (returned StateUnhandled)
984
+ pass
984
985
  ```
985
986
  """
986
987
  async for response in self.request_stream(packet, timeout):
@@ -272,6 +272,62 @@ def ceiling_device(emulator_server: int, emulator_api_url: str):
272
272
  pass # Best effort cleanup
273
273
 
274
274
 
275
+ @pytest.fixture(scope="session")
276
+ def switch_device(emulator_server: int, emulator_api_url: str):
277
+ """Create a LIFX Switch device (product 70) for StateUnhandled testing.
278
+
279
+ The Switch device does not support Light commands (GetColor, SetColor, etc.)
280
+ and will return StateUnhandled responses. This is useful for testing that
281
+ the library correctly handles unsupported command responses.
282
+
283
+ Returns:
284
+ DeviceConnection instance for the Switch device
285
+ """
286
+ from lifx.network.connection import DeviceConnection
287
+
288
+ # Wait for API to be ready (emulator might not have HTTP API ready immediately)
289
+ max_retries = 10
290
+ for i in range(max_retries):
291
+ try:
292
+ response = requests.get(f"{emulator_api_url}/docs", timeout=1.0)
293
+ if response.status_code == 200:
294
+ break
295
+ except requests.exceptions.ConnectionError:
296
+ if i < max_retries - 1:
297
+ time.sleep(0.5)
298
+ else:
299
+ raise
300
+
301
+ # Create Switch device via API (product 70 = LIFX Switch)
302
+ response = requests.post(
303
+ f"{emulator_api_url}/devices",
304
+ json={
305
+ "product_id": 70, # LIFX Switch
306
+ # Use serial that doesn't conflict with existing devices
307
+ "serial": "d073d5000200",
308
+ },
309
+ timeout=5.0,
310
+ )
311
+ response.raise_for_status() # 201 Created is expected
312
+
313
+ try:
314
+ switch = DeviceConnection(
315
+ serial="d073d5000200",
316
+ ip="127.0.0.1",
317
+ port=emulator_server,
318
+ )
319
+ yield switch
320
+ finally:
321
+ # Clean up: delete the device
322
+ try:
323
+ requests.delete(
324
+ f"{emulator_api_url}/devices/d073d5000200",
325
+ timeout=5.0,
326
+ )
327
+ except Exception:
328
+ pass # Best effort cleanup
329
+
330
+
275
331
  @pytest.fixture
276
332
  def scenario_manager(emulator_api_url: str):
277
333
  """Provide a context manager for scenario management.
@@ -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
- assert effect.palette is not None
181
- assert len(effect.palette) > 0
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 test_effect_default_palette(self) -> None:
557
- """Test that default palette is created if not provided."""
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 not None
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."""
@@ -351,7 +351,7 @@ class TestDeviceConnectionRequestStream:
351
351
  as_dict: dict[str, object] = {}
352
352
 
353
353
  with patch.object(conn, "_ensure_open", return_value=None):
354
- with pytest.raises(LifxUnsupportedCommandError, match="auto-handle"):
354
+ with pytest.raises(LifxProtocolError, match="auto-handle"):
355
355
  async for _ in conn.request_stream(UnknownPacket()):
356
356
  pass
357
357
 
@@ -381,8 +381,8 @@ class TestDeviceConnectionRequestStream:
381
381
  )
382
382
 
383
383
  async def mock_ack_stream_impl(packet, timeout=None, max_retries=None):
384
- # Yield once to indicate ACK received
385
- yield
384
+ # Yield True to indicate ACK received
385
+ yield True
386
386
 
387
387
  with (
388
388
  patch.object(conn, "_ensure_open", return_value=None),
@@ -546,3 +546,51 @@ class TestDeviceConnectionRequestStream:
546
546
 
547
547
  with pytest.raises(LifxTimeoutError, match="No response from"):
548
548
  await conn.request(get_packet)
549
+
550
+
551
+ class TestStateUnhandledResponses:
552
+ """Test StateUnhandled responses from devices that don't support commands."""
553
+
554
+ @pytest.mark.emulator
555
+ async def test_get_color_returns_state_unhandled_for_switch(
556
+ self, switch_device
557
+ ) -> None:
558
+ """Test GetColor to a Switch device returns StateUnhandled packet.
559
+
560
+ Switch devices don't support Light commands, so GetColor should
561
+ return a StateUnhandled packet instead of raising an exception.
562
+ """
563
+ from lifx.protocol import packets
564
+
565
+ async with switch_device:
566
+ # Send GetColor to a Switch - should return StateUnhandled
567
+ response = await switch_device.request(packets.Light.GetColor())
568
+
569
+ # Should return StateUnhandled packet, not raise an exception
570
+ assert isinstance(response, packets.Device.StateUnhandled)
571
+ # The unhandled_type field contains the packet type that wasn't handled
572
+ assert response.unhandled_type == packets.Light.GetColor.PKT_TYPE
573
+
574
+ @pytest.mark.emulator
575
+ async def test_set_color_returns_false_for_switch(self, switch_device) -> None:
576
+ """Test SetColor to a Switch device returns False.
577
+
578
+ Switch devices don't support Light commands, so SetColor should
579
+ return False (indicating StateUnhandled) instead of True (ACK).
580
+ """
581
+ from lifx.color import HSBK
582
+ from lifx.protocol import packets
583
+
584
+ async with switch_device:
585
+ # Create a SetColor packet
586
+ color = HSBK(hue=120, saturation=1.0, brightness=1.0, kelvin=3500)
587
+ set_packet = packets.Light.SetColor(
588
+ color=color.to_protocol(),
589
+ duration=0,
590
+ )
591
+
592
+ # Send SetColor to a Switch - should return False (StateUnhandled)
593
+ result = await switch_device.request(set_packet)
594
+
595
+ # Should return False, not True or raise an exception
596
+ assert result is False
@@ -432,7 +432,7 @@ wheels = [
432
432
 
433
433
  [[package]]
434
434
  name = "lifx-async"
435
- version = "4.3.4"
435
+ version = "4.3.6"
436
436
  source = { editable = "." }
437
437
 
438
438
  [package.dev-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes