lifx-async 4.3.8__tar.gz → 4.4.0__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 (152) hide show
  1. {lifx_async-4.3.8 → lifx_async-4.4.0}/.github/workflows/ci.yml +8 -6
  2. {lifx_async-4.3.8 → lifx_async-4.4.0}/.github/workflows/docs.yml +3 -3
  3. {lifx_async-4.3.8 → lifx_async-4.4.0}/CLAUDE.md +15 -26
  4. {lifx_async-4.3.8 → lifx_async-4.4.0}/PKG-INFO +1 -1
  5. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/changelog.md +16 -0
  6. {lifx_async-4.3.8 → lifx_async-4.4.0}/pyproject.toml +8 -4
  7. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/__init__.py +10 -0
  8. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/api.py +19 -23
  9. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/const.py +2 -1
  10. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/devices/__init__.py +12 -9
  11. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/devices/base.py +590 -58
  12. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/devices/hev.py +168 -8
  13. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/devices/infrared.py +117 -4
  14. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/devices/light.py +175 -10
  15. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/devices/matrix.py +172 -14
  16. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/devices/multizone.py +156 -21
  17. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/network/connection.py +5 -6
  18. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/protocol/generator.py +41 -0
  19. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/protocol/protocol_types.py +9 -4
  20. lifx_async-4.4.0/tests/conftest.py +535 -0
  21. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_api/test_api_apply_theme.py +0 -3
  22. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_api/test_api_batch_errors.py +100 -77
  23. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_api/test_api_batch_operations.py +3 -0
  24. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_api/test_api_discovery.py +60 -41
  25. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_api/test_api_organization.py +5 -0
  26. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_color.py +4 -0
  27. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/test_base.py +35 -28
  28. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/test_matrix.py +54 -0
  29. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/test_multizone.py +2 -2
  30. lifx_async-4.4.0/tests/test_devices/test_state_hev.py +281 -0
  31. lifx_async-4.4.0/tests/test_devices/test_state_infrared.py +199 -0
  32. lifx_async-4.4.0/tests/test_devices/test_state_light.py +528 -0
  33. lifx_async-4.4.0/tests/test_devices/test_state_management.py +1064 -0
  34. lifx_async-4.4.0/tests/test_devices/test_state_matrix.py +197 -0
  35. lifx_async-4.4.0/tests/test_devices/test_state_multizone.py +199 -0
  36. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_effects/test_colorloop.py +187 -0
  37. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_network/test_concurrent_requests.py +49 -30
  38. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_network/test_connection.py +1 -0
  39. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_network/test_discovery_devices.py +12 -16
  40. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_network/test_discovery_errors.py +6 -4
  41. lifx_async-4.4.0/tests/test_network/test_transport.py +382 -0
  42. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_protocol/test_serializer.py +224 -0
  43. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_theme/test_apply_theme.py +3 -0
  44. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_theme/test_canvas.py +64 -0
  45. {lifx_async-4.3.8 → lifx_async-4.4.0}/uv.lock +11 -205
  46. lifx_async-4.3.8/tests/conftest.py +0 -470
  47. lifx_async-4.3.8/tests/test_network/test_transport.py +0 -67
  48. {lifx_async-4.3.8 → lifx_async-4.4.0}/.claude/settings.json +0 -0
  49. {lifx_async-4.3.8 → lifx_async-4.4.0}/.github/dependabot.yml +0 -0
  50. {lifx_async-4.3.8 → lifx_async-4.4.0}/.github/labeler.yml +0 -0
  51. {lifx_async-4.3.8 → lifx_async-4.4.0}/.github/workflows/pr-automation.yml +0 -0
  52. {lifx_async-4.3.8 → lifx_async-4.4.0}/.gitignore +0 -0
  53. {lifx_async-4.3.8 → lifx_async-4.4.0}/.pre-commit-config.yaml +0 -0
  54. {lifx_async-4.3.8 → lifx_async-4.4.0}/LICENSE +0 -0
  55. {lifx_async-4.3.8 → lifx_async-4.4.0}/README.md +0 -0
  56. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/colors.md +0 -0
  57. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/devices.md +0 -0
  58. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/effects.md +0 -0
  59. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/exceptions.md +0 -0
  60. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/high-level.md +0 -0
  61. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/index.md +0 -0
  62. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/network.md +0 -0
  63. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/protocol.md +0 -0
  64. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/api/themes.md +0 -0
  65. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/architecture/effects-architecture.md +0 -0
  66. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/architecture/overview.md +0 -0
  67. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/faq.md +0 -0
  68. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/getting-started/effects.md +0 -0
  69. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/getting-started/installation.md +0 -0
  70. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/getting-started/quickstart.md +0 -0
  71. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/getting-started/themes.md +0 -0
  72. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/index.md +0 -0
  73. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/migration/effect-api-changes.md +0 -0
  74. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/stylesheets/extra.css +0 -0
  75. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/user-guide/advanced-usage.md +0 -0
  76. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/user-guide/effects-custom.md +0 -0
  77. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/user-guide/effects-troubleshooting.md +0 -0
  78. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/user-guide/protocol-deep-dive.md +0 -0
  79. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/user-guide/themes.md +0 -0
  80. {lifx_async-4.3.8 → lifx_async-4.4.0}/docs/user-guide/troubleshooting.md +0 -0
  81. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/01_simple_discovery.py +0 -0
  82. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/02_simple_control.py +0 -0
  83. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/03_waveforms.py +0 -0
  84. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/04_logging.py +0 -0
  85. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/06_pulse_effect.py +0 -0
  86. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/07_colorloop_effect.py +0 -0
  87. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/08_custom_effect.py +0 -0
  88. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/09_background_effect.py +0 -0
  89. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/10_find_specific_devices.py +0 -0
  90. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/11_matrix_basic.py +0 -0
  91. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/12_matrix_effects.py +0 -0
  92. {lifx_async-4.3.8 → lifx_async-4.4.0}/examples/13_matrix_large.py +0 -0
  93. {lifx_async-4.3.8 → lifx_async-4.4.0}/mkdocs.yml +0 -0
  94. {lifx_async-4.3.8 → lifx_async-4.4.0}/renovate.json +0 -0
  95. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/color.py +0 -0
  96. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/effects/__init__.py +0 -0
  97. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/effects/base.py +0 -0
  98. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/effects/colorloop.py +0 -0
  99. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/effects/conductor.py +0 -0
  100. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/effects/const.py +0 -0
  101. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/effects/models.py +0 -0
  102. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/effects/pulse.py +0 -0
  103. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/effects/state_manager.py +0 -0
  104. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/exceptions.py +0 -0
  105. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/network/__init__.py +0 -0
  106. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/network/discovery.py +0 -0
  107. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/network/message.py +0 -0
  108. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/network/transport.py +0 -0
  109. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/products/__init__.py +0 -0
  110. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/products/generator.py +0 -0
  111. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/products/registry.py +0 -0
  112. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/protocol/__init__.py +0 -0
  113. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/protocol/base.py +0 -0
  114. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/protocol/header.py +0 -0
  115. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/protocol/models.py +0 -0
  116. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/protocol/packets.py +0 -0
  117. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/protocol/serializer.py +0 -0
  118. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/py.typed +0 -0
  119. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/theme/__init__.py +0 -0
  120. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/theme/canvas.py +0 -0
  121. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/theme/generators.py +0 -0
  122. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/theme/library.py +0 -0
  123. {lifx_async-4.3.8 → lifx_async-4.4.0}/src/lifx/theme/theme.py +0 -0
  124. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/__init__.py +0 -0
  125. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_api/__init__.py +0 -0
  126. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/__init__.py +0 -0
  127. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/conftest.py +0 -0
  128. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/test_hev.py +0 -0
  129. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/test_infrared.py +0 -0
  130. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/test_light.py +0 -0
  131. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_devices/test_mac_address.py +0 -0
  132. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_effects/__init__.py +0 -0
  133. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_effects/test_base.py +0 -0
  134. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_effects/test_capability_filtering.py +0 -0
  135. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_effects/test_integration.py +0 -0
  136. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_effects/test_models.py +0 -0
  137. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_effects/test_pulse.py +0 -0
  138. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_effects/test_state_manager.py +0 -0
  139. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_network/__init__.py +0 -0
  140. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_network/test_message.py +0 -0
  141. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_network/test_message_advanced.py +0 -0
  142. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_products/test_product_generator.py +0 -0
  143. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_products/test_registry.py +0 -0
  144. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_protocol/test_generated.py +0 -0
  145. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_protocol/test_header.py +0 -0
  146. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_protocol/test_protocol_generator.py +0 -0
  147. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_theme/__init__.py +0 -0
  148. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_theme/conftest.py +0 -0
  149. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_theme/test_generators.py +0 -0
  150. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_theme/test_library.py +0 -0
  151. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_theme/test_theme.py +0 -0
  152. {lifx_async-4.3.8 → lifx_async-4.4.0}/tests/test_utils.py +0 -0
@@ -35,7 +35,7 @@ jobs:
35
35
  - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
36
36
 
37
37
  - name: Set up Python
38
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
38
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
39
39
  with:
40
40
  python-version: ${{ env.PYTHON_VERSION }}
41
41
 
@@ -59,8 +59,8 @@ jobs:
59
59
 
60
60
  - name: Check for security issues
61
61
  run: |
62
- uv pip install bandit
63
- uv run bandit -r src/
62
+ uv pip install "bandit[toml]"
63
+ uv run bandit -c pyproject.toml -r src/
64
64
 
65
65
  # Testing matrix across Python versions and platforms
66
66
  test:
@@ -77,7 +77,7 @@ jobs:
77
77
  - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
78
78
 
79
79
  - name: Set up Python ${{ matrix.python-version }}
80
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
80
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
81
81
  with:
82
82
  python-version: ${{ matrix.python-version }}
83
83
 
@@ -95,6 +95,7 @@ jobs:
95
95
  run: uv run --frozen pytest
96
96
 
97
97
  - name: Upload coverage to Codecov
98
+ if: matrix.os == 'ubuntu-latest'
98
99
  uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
99
100
  with:
100
101
  env_vars: OS,PYTHON
@@ -105,6 +106,7 @@ jobs:
105
106
  verbose: true
106
107
 
107
108
  - name: Upload test results to Codecov
109
+ if: matrix.os == 'ubuntu-latest'
108
110
  uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
109
111
  with:
110
112
  env_vars: OS,PYTHON
@@ -127,7 +129,7 @@ jobs:
127
129
  - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
128
130
 
129
131
  - name: Set up Python
130
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
132
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
131
133
  with:
132
134
  python-version: ${{ env.PYTHON_VERSION }}
133
135
 
@@ -191,7 +193,7 @@ jobs:
191
193
  ssh-key: ${{ secrets.DEPLOY_KEY }}
192
194
 
193
195
  - name: Set up Python
194
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
196
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
195
197
  with:
196
198
  python-version: ${{ env.PYTHON_VERSION }}
197
199
 
@@ -30,7 +30,7 @@ jobs:
30
30
  fetch-depth: 0 # Fetch all history for git-revision-date-localized
31
31
 
32
32
  - name: Set up Python
33
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
33
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
34
34
  with:
35
35
  python-version: ${{ env.PYTHON_VERSION }}
36
36
 
@@ -64,7 +64,7 @@ jobs:
64
64
  fetch-depth: 0
65
65
 
66
66
  - name: Set up Python
67
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
67
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
68
68
  with:
69
69
  python-version: ${{ env.PYTHON_VERSION }}
70
70
 
@@ -92,7 +92,7 @@ jobs:
92
92
  - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
93
93
 
94
94
  - name: Set up Python
95
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
95
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
96
96
  with:
97
97
  python-version: ${{ env.PYTHON_VERSION }}
98
98
 
@@ -12,7 +12,7 @@ structures from a YAML specification.
12
12
  **Python Versions**: 3.11, 3.12, 3.13, 3.14 (tested on all versions via CI)
13
13
  **Runtime Dependencies**: Zero - completely dependency-free!
14
14
  **Async Framework**: Python's built-in `asyncio` (no external async library required)
15
- **Test Isolation**: lifx-emulator runs as subprocess, not a dependency
15
+ **Test Isolation**: lifx-emulator-core runs embedded in-process for fast, cross-platform testing
16
16
 
17
17
  ## Essential Commands
18
18
 
@@ -610,38 +610,27 @@ The `discover_devices()` function implements DoS protection through:
610
610
  Test files mirror source structure: `tests/test_devices/test_light.py` tests
611
611
  `src/lifx/devices/light.py`
612
612
 
613
- ### Integration Tests with lifx-emulator
613
+ ### Integration Tests with lifx-emulator-core
614
614
 
615
- Some tests require the `lifx-emulator` to run integration tests against real protocol implementations.
616
- The emulator runs as a **separate subprocess** and is **not** a dependency of lifx.
615
+ Some tests require `lifx-emulator-core` to run integration tests against real protocol implementations.
616
+ The emulator runs **embedded in-process** as a dev dependency, providing:
617
+ - Fast startup (~5-10ms vs 500ms+ for subprocess)
618
+ - Cross-platform support (Windows, macOS, Linux)
619
+ - Direct access to emulator internals for scenario testing
617
620
 
618
- **Setup Options**:
619
-
620
- 1. **Development setup** (recommended): Clone lifx-emulator as a sibling directory
621
- ```bash
622
- cd ..
623
- git clone https://github.com/Djelibeybi/lifx-emulator.git
624
- cd lifx-emulator
625
- uv sync
626
- cd ../lifx
627
- ```
628
-
629
- 2. **System install**: Install lifx-emulator globally (requires Python 3.13+)
630
- ```bash
631
- uv tool install lifx-emulator
632
- ```
621
+ **Setup**: The emulator is automatically installed as a dev dependency:
622
+ ```bash
623
+ uv sync # Installs lifx-emulator-core automatically
624
+ ```
633
625
 
634
626
  **Running Integration Tests**:
627
+ - Tests marked with `@pytest.mark.emulator` use the embedded emulator
635
628
  - If emulator is not available, these tests are automatically skipped
636
- - No code changes needed - pytest plugin handles everything
637
- - **Works on all Python versions (3.11+)** since emulator runs as separate process
638
-
639
- **Note**: The emulator itself requires Python 3.13+, but it runs as a subprocess so your
640
- lifx tests can run on any supported Python version (3.11-3.14).
629
+ - **Works on all Python versions (3.11+)**
641
630
 
642
631
  **External Emulator Management**:
643
632
 
644
- For cases where you want to manage the emulator separately (or test against actual hardware), you can skip the automatic emulator subprocess startup:
633
+ For cases where you want to manage the emulator separately (or test against actual hardware):
645
634
 
646
635
  ```bash
647
636
  # Use an externally managed emulator instance
@@ -654,7 +643,6 @@ LIFX_EMULATOR_EXTERNAL=1 pytest
654
643
  This is useful when:
655
644
  - Testing against actual LIFX hardware on your network
656
645
  - Running the emulator with custom configuration or device setup
657
- - Using a shared emulator instance across multiple test runs
658
646
  - Debugging emulator behavior separately from the test suite
659
647
 
660
648
  **Key Test Files:**
@@ -801,3 +789,4 @@ Critical constants are defined in `src/lifx/const.py`:
801
789
  - Button/Relay/Switch devices are explicitly out of scope (library focuses on lighting devices)
802
790
  - Not yet published to PyPI
803
791
  - Never update docs/changelog.md manually as it is auto-generated during the release process by the CI/CD workflow.
792
+ - If a field is user-visible, it must never be bytes. This means things like serial, label, location and group must always be converted to a string prior to storing it anywhere a user would be able to access it. Conversion to and from bytes should happen either as close to sending or receiving the packet as possible.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-async
3
- Version: 4.3.8
3
+ Version: 4.4.0
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.4.0 (2025-11-29)
6
+
7
+ ### Features
8
+
9
+ - **devices**: Add factory pattern with automatic type detection and state management
10
+ ([`4374248`](https://github.com/Djelibeybi/lifx-async/commit/4374248bb46cb5af1cf303866ad82b6692bb8932))
11
+
12
+
13
+ ## v4.3.9 (2025-11-27)
14
+
15
+ ### Bug Fixes
16
+
17
+ - **network**: Propagate timeout from request() to internal methods
18
+ ([`b35ebea`](https://github.com/Djelibeybi/lifx-async/commit/b35ebea46120bfd4ad9ce149f5e25125d3694b30))
19
+
20
+
5
21
  ## v4.3.8 (2025-11-25)
6
22
 
7
23
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-async"
3
- version = "4.3.8"
3
+ version = "4.4.0"
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"
@@ -31,7 +31,7 @@ classifiers = [
31
31
  [dependency-groups]
32
32
  dev = [
33
33
  "hatchling>=1.27.0",
34
- "lifx-emulator>=2.4.0",
34
+ "lifx-emulator-core>=3.0.3",
35
35
  "mkdocs-git-revision-date-localized-plugin>=1.4.7",
36
36
  "mkdocs-llmstxt>=0.4.0",
37
37
  "mkdocs-material>=9.6.22",
@@ -41,7 +41,6 @@ dev = [
41
41
  "pytest-asyncio>=0.24.0",
42
42
  "pytest-cov>=7.0.0",
43
43
  "pytest-sugar>=1.1.1",
44
- "pytest-xprocess>=1.0.2",
45
44
  "pyyaml>=6.0.3",
46
45
  "ruff>=0.14.2",
47
46
  ]
@@ -73,7 +72,6 @@ ignore = []
73
72
  "src/lifx/protocol/packets.py" = ["E501"]
74
73
  "src/lifx/products/registry.py" = ["E501"]
75
74
 
76
-
77
75
  [tool.pyright]
78
76
  typeCheckingMode = "standard"
79
77
  pythonVersion = "3.11"
@@ -84,6 +82,9 @@ exclude = [
84
82
  "**/registry.py", # Auto-generated from products.json
85
83
  ]
86
84
 
85
+ [tool.bandit]
86
+ skips = ["B101"]
87
+
87
88
  [tool.pytest.ini_options]
88
89
  testpaths = ["tests"]
89
90
  pythonpath = ["src"]
@@ -102,6 +103,9 @@ addopts = """\
102
103
  """
103
104
  asyncio_mode = "auto"
104
105
  asyncio_default_fixture_loop_scope = "function"
106
+ markers = [
107
+ "emulator: tests that require the lifx-emulator-core embedded emulator",
108
+ ]
105
109
 
106
110
  [tool.coverage.run]
107
111
  omit = [
@@ -21,12 +21,17 @@ from lifx.devices import (
21
21
  DeviceVersion,
22
22
  FirmwareInfo,
23
23
  HevLight,
24
+ HevLightState,
24
25
  InfraredLight,
26
+ InfraredLightState,
25
27
  Light,
28
+ LightState,
26
29
  MatrixEffect,
27
30
  MatrixLight,
31
+ MatrixLightState,
28
32
  MultiZoneEffect,
29
33
  MultiZoneLight,
34
+ MultiZoneLightState,
30
35
  TileInfo,
31
36
  WifiInfo,
32
37
  )
@@ -57,10 +62,15 @@ __all__ = [
57
62
  # Core classes
58
63
  "Device",
59
64
  "Light",
65
+ "LightState",
60
66
  "HevLight",
67
+ "HevLightState",
61
68
  "InfraredLight",
69
+ "InfraredLightState",
62
70
  "MultiZoneLight",
71
+ "MultiZoneLightState",
63
72
  "MatrixLight",
73
+ "MatrixLightState",
64
74
  # Color
65
75
  "HSBK",
66
76
  "Colors",
@@ -10,7 +10,6 @@ This module provides simplified interfaces for common operations:
10
10
  from __future__ import annotations
11
11
 
12
12
  import asyncio
13
- import logging
14
13
  from collections import defaultdict
15
14
  from collections.abc import AsyncGenerator, Iterator, Sequence
16
15
  from dataclasses import dataclass
@@ -27,12 +26,11 @@ from lifx.const import (
27
26
  MAX_RESPONSE_TIME,
28
27
  )
29
28
  from lifx.devices import (
29
+ CollectionInfo,
30
30
  Device,
31
- GroupInfo,
32
31
  HevLight,
33
32
  InfraredLight,
34
33
  Light,
35
- LocationInfo,
36
34
  MatrixLight,
37
35
  MultiZoneLight,
38
36
  )
@@ -44,14 +42,12 @@ from lifx.network.discovery import (
44
42
  from lifx.protocol import packets
45
43
  from lifx.theme import Theme
46
44
 
47
- _LOGGER = logging.getLogger(__name__)
48
-
49
45
 
50
46
  @dataclass
51
47
  class LocationGrouping:
52
48
  """Organizational structure for location-based grouping."""
53
49
 
54
- uuid: bytes
50
+ uuid: str
55
51
  label: str
56
52
  devices: list[Device]
57
53
  updated_at: int # Most recent updated_at from all devices
@@ -65,7 +61,7 @@ class LocationGrouping:
65
61
  class GroupGrouping:
66
62
  """Organizational structure for group-based grouping."""
67
63
 
68
- uuid: bytes
64
+ uuid: str
69
65
  label: str
70
66
  devices: list[Device]
71
67
  updated_at: int
@@ -117,8 +113,8 @@ class DeviceGroup:
117
113
  self._matrix_lights = [light for light in devices if type(light) is MatrixLight]
118
114
  self._locations_cache: dict[str, DeviceGroup] | None = None
119
115
  self._groups_cache: dict[str, DeviceGroup] | None = None
120
- self._location_metadata: dict[bytes, LocationGrouping] | None = None
121
- self._group_metadata: dict[bytes, GroupGrouping] | None = None
116
+ self._location_metadata: dict[str, LocationGrouping] | None = None
117
+ self._group_metadata: dict[str, GroupGrouping] | None = None
122
118
 
123
119
  async def __aenter__(self) -> DeviceGroup:
124
120
  """Enter async context manager."""
@@ -278,17 +274,17 @@ class DeviceGroup:
278
274
  Skips devices with empty UUID (b'\\x00' * 16).
279
275
  Logs warnings for failed queries but continues gracefully.
280
276
  """
281
- location_data: dict[bytes, list[tuple[Device, LocationInfo]]] = defaultdict(
277
+ location_data: dict[str, list[tuple[Device, CollectionInfo]]] = defaultdict(
282
278
  list
283
279
  )
284
280
 
285
281
  # Fetch all location info concurrently
286
- tasks: dict[str, asyncio.Task[LocationInfo | None]] = {}
282
+ tasks: dict[str, asyncio.Task[CollectionInfo | None]] = {}
287
283
  async with asyncio.TaskGroup() as tg:
288
284
  for device in self._devices:
289
285
  tasks[device.serial] = tg.create_task(device.get_location())
290
286
 
291
- results: list[tuple[Device, LocationInfo | None]] = []
287
+ results: list[tuple[Device, CollectionInfo | None]] = []
292
288
  for device in self._devices:
293
289
  results.append((device, tasks[device.serial].result()))
294
290
 
@@ -298,10 +294,10 @@ class DeviceGroup:
298
294
  continue
299
295
 
300
296
  # Skip empty UUIDs (unassigned)
301
- if location_info.location == b"\x00" * 16:
297
+ if location_info.uuid == "0000000000000000":
302
298
  continue
303
299
 
304
- location_data[location_info.location].append((device, location_info))
300
+ location_data[location_info.uuid].append((device, location_info))
305
301
 
306
302
  # Build metadata dictionary with conflict resolution
307
303
  self._location_metadata = {}
@@ -333,15 +329,15 @@ class DeviceGroup:
333
329
  Logs warnings for failed queries but continues gracefully.
334
330
  """
335
331
  # Collect group info from all devices concurrently
336
- group_data: dict[bytes, list[tuple[Device, GroupInfo]]] = defaultdict(list)
332
+ group_data: dict[str, list[tuple[Device, CollectionInfo]]] = defaultdict(list)
337
333
 
338
- tasks: dict[str, asyncio.Task[GroupInfo | None]] = {}
334
+ tasks: dict[str, asyncio.Task[CollectionInfo | None]] = {}
339
335
  async with asyncio.TaskGroup() as tg:
340
336
  for device in self._devices:
341
337
  tasks[device.serial] = tg.create_task(device.get_group())
342
338
 
343
339
  # Fetch all group info concurrently
344
- results: list[tuple[Device, GroupInfo | None]] = []
340
+ results: list[tuple[Device, CollectionInfo | None]] = []
345
341
  for device in self._devices:
346
342
  results.append((device, tasks[device.serial].result()))
347
343
 
@@ -351,10 +347,10 @@ class DeviceGroup:
351
347
  continue
352
348
 
353
349
  # Skip empty UUIDs (unassigned)
354
- if group_info.group == b"\x00" * 16:
350
+ if group_info.uuid == "0000000000000000":
355
351
  continue
356
352
 
357
- group_data[group_info.group].append((device, group_info))
353
+ group_data[group_info.uuid].append((device, group_info))
358
354
 
359
355
  # Build metadata dictionary with conflict resolution
360
356
  self._group_metadata = {}
@@ -397,7 +393,7 @@ class DeviceGroup:
397
393
  )
398
394
 
399
395
  result: dict[str, DeviceGroup] = {}
400
- label_uuids: dict[str, bytes] = {}
396
+ label_uuids: dict[str, str] = {}
401
397
 
402
398
  for location_uuid, grouping in self._location_metadata.items():
403
399
  label = grouping.label
@@ -405,7 +401,7 @@ class DeviceGroup:
405
401
  # Handle naming conflicts: if two different UUIDs have the same label,
406
402
  # append UUID suffix
407
403
  if label in label_uuids and label_uuids[label] != location_uuid:
408
- label = f"{label} ({location_uuid.hex()[:8]})"
404
+ label = f"{label} ({location_uuid[:8]})"
409
405
 
410
406
  label_uuids[label] = location_uuid
411
407
  result[label] = DeviceGroup(grouping.devices)
@@ -436,7 +432,7 @@ class DeviceGroup:
436
432
  )
437
433
 
438
434
  result: dict[str, DeviceGroup] = {}
439
- label_uuids: dict[str, bytes] = {}
435
+ label_uuids: dict[str, str] = {}
440
436
 
441
437
  for group_uuid, grouping in self._group_metadata.items():
442
438
  label = grouping.label
@@ -444,7 +440,7 @@ class DeviceGroup:
444
440
  # Handle naming conflicts: if two different UUIDs have the same label,
445
441
  # append UUID suffix
446
442
  if label in label_uuids and label_uuids[label] != group_uuid:
447
- label = f"{label} ({group_uuid.hex()[:8]})"
443
+ label = f"{label} ({group_uuid[:8]})"
448
444
 
449
445
  label_uuids[label] = group_uuid
450
446
  result[label] = DeviceGroup(grouping.devices)
@@ -32,7 +32,8 @@ MAX_RESPONSE_TIME: Final[float] = 1.0 # 1 second
32
32
  IDLE_TIMEOUT_MULTIPLIER: Final[float] = 4.0 # 4 seconds (1.0 x 4.0)
33
33
 
34
34
  # Default timeout for device requests in seconds
35
- DEFAULT_REQUEST_TIMEOUT: Final[float] = 8.0
35
+ DEFAULT_REQUEST_TIMEOUT: Final[float] = 16.0
36
+ STATE_REFRESH_DEBOUNCE_MS: Final[int] = 300
36
37
 
37
38
  # Default maximum number of retry attempts for failed requests
38
39
  DEFAULT_MAX_RETRIES: Final[int] = 8
@@ -3,34 +3,37 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from lifx.devices.base import (
6
+ CollectionInfo,
6
7
  Device,
7
8
  DeviceInfo,
8
9
  DeviceVersion,
9
10
  FirmwareInfo,
10
- GroupInfo,
11
- LocationInfo,
12
11
  WifiInfo,
13
12
  )
14
- from lifx.devices.hev import HevLight
15
- from lifx.devices.infrared import InfraredLight
16
- from lifx.devices.light import Light
17
- from lifx.devices.matrix import MatrixEffect, MatrixLight, TileInfo
18
- from lifx.devices.multizone import MultiZoneEffect, MultiZoneLight
13
+ from lifx.devices.hev import HevLight, HevLightState
14
+ from lifx.devices.infrared import InfraredLight, InfraredLightState
15
+ from lifx.devices.light import Light, LightState
16
+ from lifx.devices.matrix import MatrixEffect, MatrixLight, MatrixLightState, TileInfo
17
+ from lifx.devices.multizone import MultiZoneEffect, MultiZoneLight, MultiZoneLightState
19
18
 
20
19
  __all__ = [
20
+ "CollectionInfo",
21
21
  "Device",
22
22
  "DeviceInfo",
23
23
  "DeviceVersion",
24
24
  "FirmwareInfo",
25
- "GroupInfo",
26
25
  "HevLight",
26
+ "HevLightState",
27
27
  "InfraredLight",
28
+ "InfraredLightState",
28
29
  "Light",
29
- "LocationInfo",
30
+ "LightState",
30
31
  "MatrixEffect",
31
32
  "MatrixLight",
33
+ "MatrixLightState",
32
34
  "MultiZoneEffect",
33
35
  "MultiZoneLight",
36
+ "MultiZoneLightState",
34
37
  "TileInfo",
35
38
  "WifiInfo",
36
39
  ]