lifx-async 5.1.0__tar.gz → 5.1.1__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 (186) hide show
  1. {lifx_async-5.1.0 → lifx_async-5.1.1}/.github/workflows/ci.yml +10 -10
  2. {lifx_async-5.1.0 → lifx_async-5.1.1}/.github/workflows/docs.yml +6 -6
  3. {lifx_async-5.1.0 → lifx_async-5.1.1}/PKG-INFO +1 -1
  4. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/changelog.md +11 -0
  5. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/15_animation.py +288 -14
  6. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/16_animation_numpy.py +319 -14
  7. {lifx_async-5.1.0 → lifx_async-5.1.1}/pyproject.toml +1 -1
  8. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/animation/packets.py +24 -12
  9. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/devices/base.py +80 -33
  10. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/devices/light.py +45 -22
  11. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/connection.py +46 -13
  12. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/protocol/base.py +48 -28
  13. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/protocol/header.py +3 -4
  14. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_animation/test_packets.py +4 -2
  15. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_state_light.py +140 -0
  16. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_state_management.py +235 -0
  17. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_connection.py +44 -0
  18. {lifx_async-5.1.0 → lifx_async-5.1.1}/uv.lock +1 -1
  19. {lifx_async-5.1.0 → lifx_async-5.1.1}/.claude/settings.json +0 -0
  20. {lifx_async-5.1.0 → lifx_async-5.1.1}/.github/dependabot.yml +0 -0
  21. {lifx_async-5.1.0 → lifx_async-5.1.1}/.github/labeler.yml +0 -0
  22. {lifx_async-5.1.0 → lifx_async-5.1.1}/.github/workflows/pr-automation.yml +0 -0
  23. {lifx_async-5.1.0 → lifx_async-5.1.1}/.gitignore +0 -0
  24. {lifx_async-5.1.0 → lifx_async-5.1.1}/.pre-commit-config.yaml +0 -0
  25. {lifx_async-5.1.0 → lifx_async-5.1.1}/CLAUDE.md +0 -0
  26. {lifx_async-5.1.0 → lifx_async-5.1.1}/LICENSE +0 -0
  27. {lifx_async-5.1.0 → lifx_async-5.1.1}/README.md +0 -0
  28. {lifx_async-5.1.0 → lifx_async-5.1.1}/context7.json +0 -0
  29. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/animation.md +0 -0
  30. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/colors.md +0 -0
  31. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/devices.md +0 -0
  32. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/effects.md +0 -0
  33. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/exceptions.md +0 -0
  34. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/high-level.md +0 -0
  35. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/index.md +0 -0
  36. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/network.md +0 -0
  37. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/protocol.md +0 -0
  38. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/api/themes.md +0 -0
  39. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/architecture/effects-architecture.md +0 -0
  40. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/architecture/overview.md +0 -0
  41. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/faq.md +0 -0
  42. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/getting-started/effects.md +0 -0
  43. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/getting-started/installation.md +0 -0
  44. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/getting-started/quickstart.md +0 -0
  45. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/getting-started/themes.md +0 -0
  46. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/index.md +0 -0
  47. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/migration/effect-api-changes.md +0 -0
  48. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/stylesheets/extra.css +0 -0
  49. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/user-guide/advanced-usage.md +0 -0
  50. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/user-guide/animation.md +0 -0
  51. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/user-guide/ceiling-lights.md +0 -0
  52. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/user-guide/effects-custom.md +0 -0
  53. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/user-guide/effects-troubleshooting.md +0 -0
  54. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/user-guide/protocol-deep-dive.md +0 -0
  55. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/user-guide/themes.md +0 -0
  56. {lifx_async-5.1.0 → lifx_async-5.1.1}/docs/user-guide/troubleshooting.md +0 -0
  57. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/01_simple_discovery.py +0 -0
  58. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/02_simple_control.py +0 -0
  59. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/03_waveforms.py +0 -0
  60. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/04_logging.py +0 -0
  61. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/05_device_groups.py +0 -0
  62. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/06_pulse_effect.py +0 -0
  63. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/07_colorloop_effect.py +0 -0
  64. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/08_custom_effect.py +0 -0
  65. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/09_background_effect.py +0 -0
  66. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/10_find_specific_devices.py +0 -0
  67. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/11_matrix_basic.py +0 -0
  68. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/12_matrix_effects.py +0 -0
  69. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/13_matrix_large.py +0 -0
  70. {lifx_async-5.1.0 → lifx_async-5.1.1}/examples/14_mdns_discovery.py +0 -0
  71. {lifx_async-5.1.0 → lifx_async-5.1.1}/mkdocs.yml +0 -0
  72. {lifx_async-5.1.0 → lifx_async-5.1.1}/renovate.json +0 -0
  73. {lifx_async-5.1.0 → lifx_async-5.1.1}/scripts/mdns_probe.py +0 -0
  74. {lifx_async-5.1.0 → lifx_async-5.1.1}/scripts/test_multiversion.py +0 -0
  75. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/__init__.py +0 -0
  76. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/animation/__init__.py +0 -0
  77. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/animation/animator.py +0 -0
  78. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/animation/framebuffer.py +0 -0
  79. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/animation/orientation.py +0 -0
  80. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/api.py +0 -0
  81. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/color.py +0 -0
  82. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/const.py +0 -0
  83. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/devices/__init__.py +0 -0
  84. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/devices/ceiling.py +0 -0
  85. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/devices/hev.py +0 -0
  86. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/devices/infrared.py +0 -0
  87. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/devices/matrix.py +0 -0
  88. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/devices/multizone.py +0 -0
  89. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/effects/__init__.py +0 -0
  90. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/effects/base.py +0 -0
  91. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/effects/colorloop.py +0 -0
  92. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/effects/conductor.py +0 -0
  93. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/effects/const.py +0 -0
  94. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/effects/models.py +0 -0
  95. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/effects/pulse.py +0 -0
  96. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/effects/state_manager.py +0 -0
  97. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/exceptions.py +0 -0
  98. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/__init__.py +0 -0
  99. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/discovery.py +0 -0
  100. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/mdns/__init__.py +0 -0
  101. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/mdns/discovery.py +0 -0
  102. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/mdns/dns.py +0 -0
  103. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/mdns/transport.py +0 -0
  104. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/mdns/types.py +0 -0
  105. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/message.py +0 -0
  106. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/transport.py +0 -0
  107. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/network/utils.py +0 -0
  108. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/products/__init__.py +0 -0
  109. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/products/generator.py +0 -0
  110. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/products/quirks.py +0 -0
  111. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/products/registry.py +0 -0
  112. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/protocol/__init__.py +0 -0
  113. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/protocol/generator.py +0 -0
  114. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/protocol/models.py +0 -0
  115. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/protocol/packets.py +0 -0
  116. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/protocol/protocol_types.py +0 -0
  117. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/protocol/serializer.py +0 -0
  118. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/py.typed +0 -0
  119. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/theme/__init__.py +0 -0
  120. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/theme/canvas.py +0 -0
  121. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/theme/generators.py +0 -0
  122. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/theme/library.py +0 -0
  123. {lifx_async-5.1.0 → lifx_async-5.1.1}/src/lifx/theme/theme.py +0 -0
  124. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/__init__.py +0 -0
  125. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/conftest.py +0 -0
  126. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_animation/__init__.py +0 -0
  127. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_animation/conftest.py +0 -0
  128. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_animation/test_animator.py +0 -0
  129. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_animation/test_framebuffer.py +0 -0
  130. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_animation/test_orientation.py +0 -0
  131. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_api/__init__.py +0 -0
  132. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_api/test_api_apply_theme.py +0 -0
  133. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_api/test_api_batch_errors.py +0 -0
  134. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_api/test_api_batch_operations.py +0 -0
  135. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_api/test_api_discovery.py +0 -0
  136. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_api/test_api_organization.py +0 -0
  137. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_color.py +0 -0
  138. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/__init__.py +0 -0
  139. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/conftest.py +0 -0
  140. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_base.py +0 -0
  141. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_ceiling.py +0 -0
  142. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_hev.py +0 -0
  143. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_infrared.py +0 -0
  144. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_light.py +0 -0
  145. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_mac_address.py +0 -0
  146. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_matrix.py +0 -0
  147. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_multizone.py +0 -0
  148. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_state_ceiling.py +0 -0
  149. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_state_hev.py +0 -0
  150. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_state_infrared.py +0 -0
  151. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_state_matrix.py +0 -0
  152. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_devices/test_state_multizone.py +0 -0
  153. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_effects/__init__.py +0 -0
  154. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_effects/test_base.py +0 -0
  155. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_effects/test_capability_filtering.py +0 -0
  156. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_effects/test_colorloop.py +0 -0
  157. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_effects/test_integration.py +0 -0
  158. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_effects/test_models.py +0 -0
  159. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_effects/test_pulse.py +0 -0
  160. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_effects/test_state_manager.py +0 -0
  161. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/__init__.py +0 -0
  162. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_concurrent_requests.py +0 -0
  163. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_discovery_devices.py +0 -0
  164. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_discovery_errors.py +0 -0
  165. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_mdns/__init__.py +0 -0
  166. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_mdns/conftest.py +0 -0
  167. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_mdns/test_discovery.py +0 -0
  168. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_mdns/test_dns.py +0 -0
  169. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_mdns/test_transport.py +0 -0
  170. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_message.py +0 -0
  171. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_message_advanced.py +0 -0
  172. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_network/test_transport.py +0 -0
  173. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_products/test_product_generator.py +0 -0
  174. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_products/test_registry.py +0 -0
  175. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_protocol/test_generated.py +0 -0
  176. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_protocol/test_header.py +0 -0
  177. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_protocol/test_protocol_generator.py +0 -0
  178. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_protocol/test_serializer.py +0 -0
  179. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_theme/__init__.py +0 -0
  180. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_theme/conftest.py +0 -0
  181. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_theme/test_apply_theme.py +0 -0
  182. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_theme/test_canvas.py +0 -0
  183. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_theme/test_generators.py +0 -0
  184. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_theme/test_library.py +0 -0
  185. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_theme/test_theme.py +0 -0
  186. {lifx_async-5.1.0 → lifx_async-5.1.1}/tests/test_utils.py +0 -0
@@ -32,7 +32,7 @@ jobs:
32
32
  name: Code Quality
33
33
  runs-on: ubuntu-latest
34
34
  steps:
35
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
35
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
36
36
 
37
37
  - name: Set up Python
38
38
  uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -40,7 +40,7 @@ jobs:
40
40
  python-version: ${{ env.PYTHON_VERSION }}
41
41
 
42
42
  - name: Install uv
43
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7
43
+ uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7
44
44
  with:
45
45
  version: ${{ env.UV_VERSION }}
46
46
  python-version: ${{ env.PYTHON_VERSION }}
@@ -68,7 +68,7 @@ jobs:
68
68
  needs: quality
69
69
  runs-on: ubuntu-latest
70
70
  steps:
71
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
71
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
72
72
 
73
73
  - name: Set up Python
74
74
  uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -76,7 +76,7 @@ jobs:
76
76
  python-version: ${{ env.PYTHON_VERSION }}
77
77
 
78
78
  - name: Install uv
79
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7
79
+ uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7
80
80
  with:
81
81
  version: ${{ env.UV_VERSION }}
82
82
  python-version: ${{ env.PYTHON_VERSION }}
@@ -115,7 +115,7 @@ jobs:
115
115
  os: [ubuntu-latest, macos-latest, windows-latest]
116
116
  python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
117
117
  steps:
118
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
118
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
119
119
 
120
120
  - name: Set up Python ${{ matrix.python-version }}
121
121
  uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -123,7 +123,7 @@ jobs:
123
123
  python-version: ${{ matrix.python-version }}
124
124
 
125
125
  - name: Install uv
126
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7
126
+ uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7
127
127
  with:
128
128
  version: ${{ env.UV_VERSION }}
129
129
  python-version: ${{ matrix.python-version }}
@@ -167,7 +167,7 @@ jobs:
167
167
  if: github.event_name == 'push' && github.ref_name == 'main'
168
168
  runs-on: ubuntu-latest
169
169
  steps:
170
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
170
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
171
171
 
172
172
  - name: Set up Python
173
173
  uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -175,7 +175,7 @@ jobs:
175
175
  python-version: ${{ env.PYTHON_VERSION }}
176
176
 
177
177
  - name: Install uv
178
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7
178
+ uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7
179
179
  with:
180
180
  version: ${{ env.UV_VERSION }}
181
181
  python-version: ${{ env.PYTHON_VERSION }}
@@ -228,7 +228,7 @@ jobs:
228
228
 
229
229
  steps:
230
230
  - name: Checkout repository on release branch
231
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
231
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
232
232
  with:
233
233
  ref: ${{ github.head_ref || github.ref_name }}
234
234
  fetch-depth: 0
@@ -240,7 +240,7 @@ jobs:
240
240
  python-version: ${{ env.PYTHON_VERSION }}
241
241
 
242
242
  - name: Install uv
243
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7
243
+ uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7
244
244
  with:
245
245
  version: ${{ env.UV_VERSION }}
246
246
  python-version: ${{ env.PYTHON_VERSION }}
@@ -25,7 +25,7 @@ jobs:
25
25
  build-docs:
26
26
  runs-on: ubuntu-latest
27
27
  steps:
28
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
28
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
29
29
  with:
30
30
  fetch-depth: 0 # Fetch all history for git-revision-date-localized
31
31
 
@@ -35,7 +35,7 @@ jobs:
35
35
  python-version: ${{ env.PYTHON_VERSION }}
36
36
 
37
37
  - name: Install uv
38
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7
38
+ uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7
39
39
  with:
40
40
  version: ${{ env.UV_VERSION }}
41
41
  python-version: ${{ env.PYTHON_VERSION }}
@@ -59,7 +59,7 @@ jobs:
59
59
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
60
60
  needs: build-docs
61
61
  steps:
62
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
62
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
63
63
  with:
64
64
  fetch-depth: 0
65
65
 
@@ -69,7 +69,7 @@ jobs:
69
69
  python-version: ${{ env.PYTHON_VERSION }}
70
70
 
71
71
  - name: Install uv
72
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7
72
+ uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7
73
73
  with:
74
74
  version: ${{ env.UV_VERSION }}
75
75
  python-version: ${{ env.PYTHON_VERSION }}
@@ -89,7 +89,7 @@ jobs:
89
89
  validate-links:
90
90
  runs-on: ubuntu-latest
91
91
  steps:
92
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
92
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
93
93
 
94
94
  - name: Set up Python
95
95
  uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -97,7 +97,7 @@ jobs:
97
97
  python-version: ${{ env.PYTHON_VERSION }}
98
98
 
99
99
  - name: Install uv
100
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7
100
+ uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7
101
101
  with:
102
102
  version: ${{ env.UV_VERSION }}
103
103
  python-version: ${{ env.PYTHON_VERSION }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 5.1.0
3
+ Version: 5.1.1
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,17 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v5.1.1 (2026-02-06)
6
+
7
+ ### Performance Improvements
8
+
9
+ - Optimize device initialization and packet sending
10
+ ([`865c4e3`](https://github.com/Djelibeybi/lifx-async/commit/865c4e32f696881a4e0ed2932eb014c4136d0554))
11
+
12
+ - Pure-Python optimizations for protocol, network, and animation layers
13
+ ([`a9a4bd4`](https://github.com/Djelibeybi/lifx-async/commit/a9a4bd40e35095c079339b336d82f1f5d509560a))
14
+
15
+
5
16
  ## v5.1.0 (2026-01-24)
6
17
 
7
18
  ### Bug Fixes
@@ -6,14 +6,30 @@ Path) or multizone (Strip, Beam) device and runs an appropriate animation.
6
6
 
7
7
  The animation module sends frames via direct UDP for maximum throughput -
8
8
  no connection layer overhead, no ACKs, just fire packets as fast as possible.
9
+
10
+ Use --profile to run performance benchmarks:
11
+ --profile alone: runs synthetic micro-benchmarks (no device needed)
12
+ --profile with --serial/--ip: also profiles the animation loop against a device
9
13
  """
10
14
 
11
15
  import argparse
12
16
  import asyncio
13
17
  import math
18
+ import statistics
14
19
  import time
15
20
 
16
- from lifx import Animator, MatrixLight, MultiZoneLight, find_by_ip, find_by_serial
21
+ from lifx import (
22
+ Animator,
23
+ Device,
24
+ MatrixLight,
25
+ MultiZoneLight,
26
+ find_by_ip,
27
+ find_by_serial,
28
+ )
29
+ from lifx.animation.packets import MatrixPacketGenerator, MultiZonePacketGenerator
30
+ from lifx.protocol.header import LifxHeader
31
+ from lifx.protocol.packets import Light
32
+ from lifx.protocol.protocol_types import LightHsbk
17
33
 
18
34
 
19
35
  def print_animator_info(animator: Animator) -> None:
@@ -25,6 +41,207 @@ def print_animator_info(animator: Animator) -> None:
25
41
  print("---------------------\n")
26
42
 
27
43
 
44
+ def percentile_stats(times_ms: list[float]) -> dict[str, float]:
45
+ """Compute summary statistics from a list of times in milliseconds."""
46
+ if not times_ms:
47
+ return {
48
+ "mean": 0.0,
49
+ "median": 0.0,
50
+ "p95": 0.0,
51
+ "p99": 0.0,
52
+ "min": 0.0,
53
+ "max": 0.0,
54
+ }
55
+ times_ms.sort()
56
+ n = len(times_ms)
57
+ return {
58
+ "mean": statistics.mean(times_ms),
59
+ "median": statistics.median(times_ms),
60
+ "p95": times_ms[int(n * 0.95)],
61
+ "p99": times_ms[int(n * 0.99)],
62
+ "min": times_ms[0],
63
+ "max": times_ms[-1],
64
+ }
65
+
66
+
67
+ def print_stats(label: str, stats: dict[str, float]) -> None:
68
+ """Print a single stats line."""
69
+ print(
70
+ f" {label}:\n"
71
+ f" mean={stats['mean']:.3f}ms median={stats['median']:.3f}ms "
72
+ f"p95={stats['p95']:.3f}ms p99={stats['p99']:.3f}ms "
73
+ f"min={stats['min']:.3f}ms max={stats['max']:.3f}ms"
74
+ )
75
+
76
+
77
+ async def run_profile(
78
+ device: MatrixLight | MultiZoneLight,
79
+ iterations: int = 2000,
80
+ warmup: int = 200,
81
+ ) -> None:
82
+ """Profile the animation loop against a real device."""
83
+ is_matrix = isinstance(device, MatrixLight)
84
+
85
+ if is_matrix:
86
+ animator = await Animator.for_matrix(device)
87
+ canvas_width = animator.canvas_width
88
+ canvas_height = animator.canvas_height
89
+ else:
90
+ animator = await Animator.for_multizone(device)
91
+ canvas_width = animator.pixel_count
92
+ canvas_height = 1
93
+
94
+ pixel_count = animator.pixel_count
95
+
96
+ # Pre-compute wave constants for matrix
97
+ wave_angle = math.radians(30)
98
+ cos_wave = math.cos(wave_angle)
99
+ sin_wave = math.sin(wave_angle)
100
+ max_pos = canvas_width * cos_wave + canvas_height * sin_wave
101
+
102
+ gen_times: list[float] = []
103
+ send_times: list[float] = []
104
+
105
+ total_iterations = warmup + iterations
106
+ print(
107
+ f"\n=== Animation Profile ({iterations} iterations, "
108
+ f"{'matrix' if is_matrix else 'multizone'} "
109
+ f"{canvas_width}x{canvas_height}, {pixel_count} pixels) ==="
110
+ )
111
+ print(f" Warmup: {warmup} iterations")
112
+
113
+ try:
114
+ for i in range(total_iterations):
115
+ hue_offset = (i * 1000) % 65536
116
+
117
+ # Time frame generation
118
+ t0 = time.perf_counter()
119
+ if is_matrix:
120
+ frame = []
121
+ for y in range(canvas_height):
122
+ for x in range(canvas_width):
123
+ pos = x * cos_wave + y * sin_wave
124
+ hue = int((pos / max_pos) * 65535 + hue_offset) % 65536
125
+ frame.append((hue, 65535, 65535, 3500))
126
+ else:
127
+ frame = []
128
+ for j in range(pixel_count):
129
+ hue_val = int((j / pixel_count) * 65536)
130
+ hue = (hue_offset + hue_val) % 65536
131
+ frame.append((hue, 65535, 65535, 3500))
132
+ t1 = time.perf_counter()
133
+
134
+ # Time send_frame
135
+ animator.send_frame(frame)
136
+ t2 = time.perf_counter()
137
+
138
+ if i >= warmup:
139
+ gen_times.append((t1 - t0) * 1000)
140
+ send_times.append((t2 - t1) * 1000)
141
+ finally:
142
+ animator.close()
143
+
144
+ total_times = [g + s for g, s in zip(gen_times, send_times)]
145
+
146
+ print()
147
+ print_stats("Frame generation", percentile_stats(gen_times))
148
+ print_stats("send_frame (orient + pack + send)", percentile_stats(send_times))
149
+ print_stats("Total per-frame", percentile_stats(total_times))
150
+
151
+ if total_times:
152
+ mean_total = statistics.mean(total_times)
153
+ if mean_total > 0:
154
+ throughput = 1000.0 / mean_total
155
+ print(f"\n Throughput: {throughput:,.0f} frames/sec")
156
+
157
+
158
+ def _bench(label: str, func: object, n: int) -> None:
159
+ """Run a tight-loop benchmark and print results."""
160
+ t0 = time.perf_counter()
161
+ for _ in range(n):
162
+ func() # type: ignore[operator]
163
+ elapsed = time.perf_counter() - t0
164
+ rate = n / elapsed
165
+ per_call = elapsed / n * 1000
166
+ print(
167
+ f"{label}:\n"
168
+ f" {n:,} calls in {elapsed:.2f}s "
169
+ f"({rate:,.0f} calls/sec, {per_call:.4f}ms/call)"
170
+ )
171
+
172
+
173
+ def run_synthetic_benchmarks() -> None:
174
+ """Run synthetic micro-benchmarks of optimized code paths."""
175
+ print("\n=== Synthetic Benchmarks ===\n")
176
+
177
+ dummy_target = b"\xd0\x73\xd5\x01\x02\x03"
178
+ dummy_source = 12345
179
+
180
+ # --- update_colors: matrix (5 tiles, 320 pixels) ---
181
+ matrix_gen = MatrixPacketGenerator(tile_count=5, tile_width=8, tile_height=8)
182
+ matrix_templates = matrix_gen.create_templates(dummy_source, dummy_target)
183
+ matrix_pixels = matrix_gen.pixel_count()
184
+ matrix_hsbk = [(32768, 65535, 32768, 3500)] * matrix_pixels
185
+
186
+ n = 100_000
187
+ _bench(
188
+ f"update_colors (matrix, 5 tiles, {matrix_pixels}px)",
189
+ lambda: matrix_gen.update_colors(matrix_templates, matrix_hsbk),
190
+ n,
191
+ )
192
+
193
+ # --- update_colors: multizone (82 zones) ---
194
+ mz_gen = MultiZonePacketGenerator(zone_count=82)
195
+ mz_templates = mz_gen.create_templates(dummy_source, dummy_target)
196
+ mz_hsbk = [(32768, 65535, 32768, 3500)] * 82
197
+
198
+ print()
199
+ _bench(
200
+ "update_colors (multizone, 82 zones)",
201
+ lambda: mz_gen.update_colors(mz_templates, mz_hsbk),
202
+ n,
203
+ )
204
+
205
+ # --- LifxHeader.pack() ---
206
+ header = LifxHeader.create(
207
+ pkt_type=102,
208
+ source=dummy_source,
209
+ target=dummy_target,
210
+ payload_size=13,
211
+ )
212
+ n_header = 500_000
213
+
214
+ print()
215
+ _bench("LifxHeader.pack()", header.pack, n_header)
216
+
217
+ # --- LifxHeader.unpack() ---
218
+ packed_header = header.pack()
219
+
220
+ print()
221
+ _bench(
222
+ "LifxHeader.unpack()",
223
+ lambda: LifxHeader.unpack(packed_header),
224
+ n_header,
225
+ )
226
+
227
+ # --- Packet.pack (Light.SetColor) ---
228
+ hsbk = LightHsbk(hue=32768, saturation=65535, brightness=32768, kelvin=3500)
229
+ packet = Light.SetColor(color=hsbk, duration=1000)
230
+
231
+ print()
232
+ _bench("Packet.pack (Light.SetColor)", packet.pack, n)
233
+
234
+ # --- Packet.unpack (Light.SetColor) ---
235
+ packed_packet = packet.pack()
236
+
237
+ print()
238
+ _bench(
239
+ "Packet.unpack (Light.SetColor)",
240
+ lambda: Light.SetColor.unpack(packed_packet),
241
+ n,
242
+ )
243
+
244
+
28
245
  async def run_matrix_animation(
29
246
  device: MatrixLight,
30
247
  duration: float = 10.0,
@@ -216,27 +433,44 @@ async def run_multizone_animation(
216
433
 
217
434
 
218
435
  async def main(
219
- serial: str,
436
+ serial: str | None = None,
220
437
  ip: str | None = None,
221
438
  duration: float = 10.0,
222
439
  fps: float = 30.0,
440
+ profile: bool = False,
441
+ iterations: int = 2000,
442
+ warmup: int = 200,
223
443
  ) -> None:
224
444
  """Find device and run appropriate animation."""
445
+ if profile and not serial and not ip:
446
+ # Synthetic-only mode
447
+ print("=" * 70)
448
+ print("LIFX Animation Profiler (synthetic benchmarks only)")
449
+ print("=" * 70)
450
+ run_synthetic_benchmarks()
451
+ return
452
+
453
+ if not serial and not ip:
454
+ print("Error: --serial or --ip is required (unless using --profile alone)")
455
+ return
456
+
225
457
  print("=" * 70)
226
- print("LIFX Animation Example")
458
+ print("LIFX Animation Example" + (" (profiling)" if profile else ""))
227
459
  print("=" * 70)
228
460
 
229
461
  # Find the device
230
- if ip:
462
+ if serial and ip:
463
+ # Both serial and IP provided - connect directly without discovery
464
+ print(f"\nConnecting directly to {ip} (serial: {serial})")
465
+ device = await Device.connect(ip=ip, serial=serial)
466
+ elif ip:
231
467
  print(f"\nSearching for device at IP: {ip}")
232
468
  device = await find_by_ip(ip)
233
469
  if device is None:
234
470
  print(f"No device found at IP '{ip}'")
235
471
  return
236
- # Verify serial matches if both provided
237
- if device.serial.lower().replace(":", "") != serial.lower().replace(":", ""):
238
- print(f"Warning: Device serial {device.serial} doesn't match {serial}")
239
472
  else:
473
+ assert serial is not None
240
474
  print(f"\nSearching for device with serial: {serial}")
241
475
  device = await find_by_serial(serial)
242
476
  if device is None:
@@ -294,20 +528,32 @@ async def main(
294
528
  await device.set_power(True)
295
529
  await asyncio.sleep(1)
296
530
 
297
- # Run appropriate animation
531
+ # Run appropriate animation or profile
298
532
  try:
299
- if is_matrix:
300
- assert isinstance(device, MatrixLight)
301
- await run_matrix_animation(device, duration, fps)
533
+ if profile:
534
+ if is_matrix:
535
+ assert isinstance(device, MatrixLight)
536
+ await run_profile(device, iterations, warmup)
537
+ else:
538
+ assert isinstance(device, MultiZoneLight)
539
+ await run_profile(device, iterations, warmup)
302
540
  else:
303
- assert isinstance(device, MultiZoneLight)
304
- await run_multizone_animation(device, duration, fps)
541
+ if is_matrix:
542
+ assert isinstance(device, MatrixLight)
543
+ await run_matrix_animation(device, duration, fps)
544
+ else:
545
+ assert isinstance(device, MultiZoneLight)
546
+ await run_multizone_animation(device, duration, fps)
305
547
  finally:
306
548
  # Restore power state
307
549
  if was_off:
308
550
  print("\nTurning device back OFF...")
309
551
  await device.set_power(False)
310
552
 
553
+ # Always run synthetic benchmarks after device profiling
554
+ if profile:
555
+ run_synthetic_benchmarks()
556
+
311
557
  print("\n" + "=" * 70)
312
558
 
313
559
 
@@ -326,6 +572,12 @@ Examples:
326
572
  # Run animation for 30 seconds at 60 FPS
327
573
  python 15_animation.py --serial d073d5123456 --duration 30 --fps 60
328
574
 
575
+ # Run synthetic benchmarks only (no device needed)
576
+ python 15_animation.py --profile
577
+
578
+ # Profile animation loop against a device + synthetic benchmarks
579
+ python 15_animation.py --serial d073d5123456 --ip 192.168.1.100 --profile
580
+
329
581
  # Serial number formats (both work):
330
582
  python 15_animation.py --serial d073d5123456
331
583
  python 15_animation.py --serial d0:73:d5:12:34:56
@@ -334,7 +586,6 @@ Examples:
334
586
  parser.add_argument(
335
587
  "--serial",
336
588
  "-s",
337
- required=True,
338
589
  help="Device serial number (12 hex digits, with or without colons)",
339
590
  )
340
591
  parser.add_argument(
@@ -356,9 +607,29 @@ Examples:
356
607
  default=30.0,
357
608
  help="Target frames per second (default: 30)",
358
609
  )
610
+ parser.add_argument(
611
+ "--profile",
612
+ action="store_true",
613
+ help="Enable profiling mode (no sleep between frames)",
614
+ )
615
+ parser.add_argument(
616
+ "--iterations",
617
+ type=int,
618
+ default=2000,
619
+ help="Number of profiling iterations (default: 2000)",
620
+ )
621
+ parser.add_argument(
622
+ "--warmup",
623
+ type=int,
624
+ default=200,
625
+ help="Warmup iterations excluded from stats (default: 200)",
626
+ )
359
627
 
360
628
  args = parser.parse_args()
361
629
 
630
+ if not args.profile and not args.serial:
631
+ parser.error("--serial is required unless using --profile alone")
632
+
362
633
  try:
363
634
  asyncio.run(
364
635
  main(
@@ -366,6 +637,9 @@ Examples:
366
637
  args.ip,
367
638
  args.duration,
368
639
  args.fps,
640
+ args.profile,
641
+ args.iterations,
642
+ args.warmup,
369
643
  )
370
644
  )
371
645
  except KeyboardInterrupt: