lifx-emulator 2.2.1__tar.gz → 2.3.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 (137) hide show
  1. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/CLAUDE.md +16 -0
  2. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/PKG-INFO +1 -1
  3. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/changelog.md +8 -0
  4. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/guide/device-types.md +11 -0
  5. lifx_emulator-2.3.0/docs/guide/framebuffers.md +209 -0
  6. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/pyproject.toml +2 -1
  7. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/devices/device.py +7 -1
  8. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/devices/state_serializer.py +27 -0
  9. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/devices/states.py +31 -0
  10. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/handlers/tile_handlers.py +142 -15
  11. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_tile_handlers_extended.py +295 -0
  12. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/uv.lock +1 -1
  13. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/.github/workflows/ci.yml +0 -0
  14. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/.github/workflows/docs.yml +0 -0
  15. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/.gitignore +0 -0
  16. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/.pre-commit-config.yaml +0 -0
  17. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/LICENSE +0 -0
  18. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/README.md +0 -0
  19. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/advanced/device-management-api.md +0 -0
  20. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/advanced/index.md +0 -0
  21. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/advanced/scenario-api.md +0 -0
  22. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/advanced/scenarios.md +0 -0
  23. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/advanced/storage.md +0 -0
  24. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/api/device.md +0 -0
  25. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/api/factories.md +0 -0
  26. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/api/index.md +0 -0
  27. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/api/products.md +0 -0
  28. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/api/protocol.md +0 -0
  29. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/api/server.md +0 -0
  30. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/api/storage.md +0 -0
  31. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/architecture/device-state.md +0 -0
  32. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/architecture/index.md +0 -0
  33. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/architecture/overview.md +0 -0
  34. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/architecture/packet-flow.md +0 -0
  35. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/architecture/protocol.md +0 -0
  36. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/assets/favicon.png +0 -0
  37. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/faq.md +0 -0
  38. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/getting-started/cli.md +0 -0
  39. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/getting-started/index.md +0 -0
  40. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/getting-started/installation.md +0 -0
  41. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/getting-started/quickstart.md +0 -0
  42. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/guide/best-practices.md +0 -0
  43. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/guide/index.md +0 -0
  44. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/guide/integration-testing.md +0 -0
  45. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/guide/products-and-specs.md +0 -0
  46. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/guide/testing-scenarios.md +0 -0
  47. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/guide/web-interface.md +0 -0
  48. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/index.md +0 -0
  49. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/reference/glossary.md +0 -0
  50. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/reference/troubleshooting.md +0 -0
  51. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/stylesheets/extra.css +0 -0
  52. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/tutorials/01-first-device.md +0 -0
  53. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/tutorials/02-basic.md +0 -0
  54. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/tutorials/03-integration.md +0 -0
  55. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/tutorials/04-advanced-scenarios.md +0 -0
  56. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/tutorials/05-cicd.md +0 -0
  57. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/docs/tutorials/index.md +0 -0
  58. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/mkdocs.yml +0 -0
  59. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/renovate.json +0 -0
  60. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/__init__.py +0 -0
  61. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/__main__.py +0 -0
  62. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/__init__.py +0 -0
  63. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/app.py +0 -0
  64. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/mappers/__init__.py +0 -0
  65. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/mappers/device_mapper.py +0 -0
  66. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/models.py +0 -0
  67. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/routers/__init__.py +0 -0
  68. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/routers/devices.py +0 -0
  69. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/routers/monitoring.py +0 -0
  70. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/routers/scenarios.py +0 -0
  71. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/services/__init__.py +0 -0
  72. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/services/device_service.py +0 -0
  73. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/api/templates/dashboard.html +0 -0
  74. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/constants.py +0 -0
  75. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/devices/__init__.py +0 -0
  76. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/devices/manager.py +0 -0
  77. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/devices/observers.py +0 -0
  78. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/devices/persistence.py +0 -0
  79. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
  80. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/factories/__init__.py +0 -0
  81. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/factories/builder.py +0 -0
  82. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/factories/default_config.py +0 -0
  83. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/factories/factory.py +0 -0
  84. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
  85. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
  86. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/handlers/__init__.py +0 -0
  87. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/handlers/base.py +0 -0
  88. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
  89. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
  90. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/handlers/multizone_handlers.py +0 -0
  91. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/handlers/registry.py +0 -0
  92. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/products/__init__.py +0 -0
  93. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/products/generator.py +0 -0
  94. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/products/registry.py +0 -0
  95. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/products/specs.py +0 -0
  96. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/products/specs.yml +0 -0
  97. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/protocol/__init__.py +0 -0
  98. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/protocol/base.py +0 -0
  99. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/protocol/const.py +0 -0
  100. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/protocol/generator.py +0 -0
  101. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/protocol/header.py +0 -0
  102. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/protocol/packets.py +0 -0
  103. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
  104. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/protocol/serializer.py +0 -0
  105. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/repositories/__init__.py +0 -0
  106. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
  107. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
  108. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
  109. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/scenarios/manager.py +0 -0
  110. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/scenarios/models.py +0 -0
  111. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
  112. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/src/lifx_emulator/server.py +0 -0
  113. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/conftest.py +0 -0
  114. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_api.py +0 -0
  115. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_api_validation.py +0 -0
  116. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_async_storage.py +0 -0
  117. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_cli.py +0 -0
  118. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_cli_validation.py +0 -0
  119. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_device.py +0 -0
  120. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_device_edge_cases.py +0 -0
  121. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_device_handlers_extended.py +0 -0
  122. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_device_manager.py +0 -0
  123. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_handler_registry.py +0 -0
  124. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_integration.py +0 -0
  125. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_light_handlers_extended.py +0 -0
  126. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_multizone_handlers_extended.py +0 -0
  127. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_observers.py +0 -0
  128. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_products_generator.py +0 -0
  129. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_products_specs.py +0 -0
  130. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_protocol_generator.py +0 -0
  131. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_protocol_types_coverage.py +0 -0
  132. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_repositories.py +0 -0
  133. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_scenario_manager.py +0 -0
  134. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_scenario_persistence.py +0 -0
  135. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_serializer.py +0 -0
  136. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_server.py +0 -0
  137. {lifx_emulator-2.2.1 → lifx_emulator-2.3.0}/tests/test_state_restorer.py +0 -0
@@ -523,6 +523,9 @@ delay = manager.get_response_delay(502, merged) # 1.0s
523
523
  - Dataclass holding all device state (color, power, zones, tiles, firmware version, etc.)
524
524
  - Capability flags: `has_color`, `has_infrared`, `has_multizone`, `has_matrix`, `has_hev`
525
525
  - Initialized differently per device type via factory functions
526
+ - **TileFramebuffers**: Internal dataclass for storing non-visible framebuffers (1-7) per tile
527
+ - Provides `get_framebuffer(fb_index, width, height)` for lazy initialization
528
+ - Framebuffer 0 remains in protocol-defined `tile_devices[i]["colors"]`
526
529
 
527
530
  ### Protocol Layer
528
531
 
@@ -748,6 +751,19 @@ product.supports_extended_multizone(131148) # False (below requirement)
748
751
  - Tiles with more than 64 zones (16×8) require multiple Get64 requests with different y coordinates
749
752
  - Tile state stored in `DeviceState.tile_devices` list, each with `colors` array
750
753
 
754
+ #### Framebuffer Support
755
+ Matrix devices support 8 framebuffers (0-7) for advanced rendering:
756
+ - **Framebuffer 0**: The visible buffer, stored in `tile_devices[i]["colors"]`
757
+ - **Framebuffers 1-7**: Non-visible buffers, stored in `MatrixState.tile_framebuffers`
758
+ - **Set64**: Respects `rect.fb_index` to update the specified framebuffer
759
+ - **Get64**: Always returns framebuffer 0 (visible buffer) regardless of request `fb_index`
760
+ - **CopyFrameBuffer**: Copies rectangular zones from one framebuffer to another
761
+ - Can copy from any FB (0-7) to any other FB (0-7)
762
+ - Supports source/destination offsets and partial rectangles
763
+ - Use to make non-visible buffers visible by copying to FB0
764
+ - Framebuffers are lazily initialized on first access
765
+ - Non-visible framebuffers are persisted with device state
766
+
751
767
  ### Testing Scenarios
752
768
  Configure via ScenarioConfig in HierarchicalScenarioManager:
753
769
  - `drop_packets`: Dict mapping packet type to drop rate (0.0-1.0, where 1.0 = always drop)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: LIFX Emulator for testing LIFX LAN protocol libraries
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -2,6 +2,14 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.3.0 (2025-11-18)
6
+
7
+ ### Features
8
+
9
+ - **tile**: Implement multi-framebuffer support for matrix devices
10
+ ([`16a69ca`](https://github.com/Djelibeybi/lifx-emulator/commit/16a69cacbdf0472d8f7116eb0acfda9808431c5c))
11
+
12
+
5
13
  ## v2.2.1 (2025-11-18)
6
14
 
7
15
  ### Bug Fixes
@@ -329,6 +329,17 @@ single tile.
329
329
  # split either by row or column.
330
330
  ```
331
331
 
332
+ ### Framebuffers (v2.3+)
333
+
334
+ Matrix devices support **8 framebuffers (0-7)** to enable atomic updates of tiles with more than 64 zones:
335
+
336
+ - **Framebuffer 0**: Visible buffer (displayed on device)
337
+ - **Framebuffers 1-7**: Non-visible buffers for preparing content off-screen
338
+
339
+ For large tiles (>64 zones), prepare all zones in a non-visible framebuffer, then use `CopyFrameBuffer` to atomically display them without flicker.
340
+
341
+ See [Framebuffer Guide](framebuffers.md) for complete documentation and examples.
342
+
332
343
 
333
344
  ## Using Generic create_device()
334
345
 
@@ -0,0 +1,209 @@
1
+ # Matrix Framebuffer Support
2
+
3
+ Matrix devices with more than 64 zones require multiple Set64 packets to update all zones. Framebuffers enable atomic updates by allowing you to prepare all zones off-screen before displaying them.
4
+
5
+ ## Overview
6
+
7
+ Matrix devices support **8 framebuffers (0-7)**:
8
+
9
+ - **Framebuffer 0**: The visible buffer displayed on the device
10
+ - **Framebuffers 1-7**: Non-visible buffers for preparing content
11
+
12
+ ## Why Framebuffers Matter
13
+
14
+ For large tiles (>64 zones), such as the LIFX Ceiling 13"x26" with 128 zones (16×8):
15
+
16
+ **Without framebuffers:**
17
+ ```
18
+ Set64(fb=0, zones 0-63) → Visible immediately (partial update)
19
+ Set64(fb=0, zones 64-127) → Visible immediately (flicker as zones update)
20
+ ```
21
+
22
+ **With framebuffers:**
23
+ ```
24
+ Set64(fb=1, zones 0-63) → Prepared off-screen
25
+ Set64(fb=1, zones 64-127) → Prepared off-screen
26
+ CopyFrameBuffer(fb=1 → fb=0) → All 128 zones appear atomically
27
+ ```
28
+
29
+ ## Framebuffer Operations
30
+
31
+ ### Set64 - Update Zones
32
+
33
+ The `rect.fb_index` field specifies which framebuffer to update:
34
+
35
+ ```python
36
+ from lifx_emulator.protocol.packets import Tile
37
+ from lifx_emulator.protocol.protocol_types import TileBufferRect, LightHsbk
38
+
39
+ # Update visible framebuffer (immediate display)
40
+ rect = TileBufferRect(fb_index=0, x=0, y=0, width=8)
41
+ packet = Tile.Set64(
42
+ tile_index=0,
43
+ rect=rect,
44
+ duration=0,
45
+ colors=[...64 colors...]
46
+ )
47
+
48
+ # Update non-visible framebuffer 1 (off-screen)
49
+ rect = TileBufferRect(fb_index=1, x=0, y=0, width=8)
50
+ packet = Tile.Set64(
51
+ tile_index=0,
52
+ rect=rect,
53
+ duration=0,
54
+ colors=[...64 colors...]
55
+ )
56
+ ```
57
+
58
+ ### Get64 - Read Zones
59
+
60
+ Get64 **always returns framebuffer 0** (the visible buffer), regardless of the `fb_index` in the request:
61
+
62
+ ```python
63
+ # Request can specify any fb_index
64
+ rect = TileBufferRect(fb_index=1, x=0, y=0, width=8)
65
+ packet = Tile.Get64(tile_index=0, rect=rect)
66
+
67
+ # Response will contain framebuffer 0 content
68
+ # Response rect.fb_index is always 0
69
+ ```
70
+
71
+ ### CopyFrameBuffer - Atomic Display
72
+
73
+ Copy zones between framebuffers to make prepared content visible:
74
+
75
+ ```python
76
+ # Copy entire framebuffer 1 to framebuffer 0 (make visible)
77
+ packet = Tile.CopyFrameBuffer(
78
+ tile_index=0,
79
+ src_fb_index=1,
80
+ dst_fb_index=0,
81
+ src_x=0,
82
+ src_y=0,
83
+ dst_x=0,
84
+ dst_y=0,
85
+ width=16,
86
+ height=8,
87
+ duration=0
88
+ )
89
+ ```
90
+
91
+ ## Complete Example: Updating a 16×8 Tile
92
+
93
+ ```python
94
+ from lifx_emulator import create_tile_device
95
+ from lifx_emulator.protocol.packets import Tile
96
+ from lifx_emulator.protocol.protocol_types import TileBufferRect, LightHsbk
97
+
98
+ # Create 16×8 tile (128 zones)
99
+ device = create_tile_device(
100
+ serial="d073dc000001",
101
+ tile_count=1,
102
+ tile_width=16,
103
+ tile_height=8
104
+ )
105
+
106
+ # Prepare colors for all 128 zones
107
+ red = LightHsbk(hue=0, saturation=65535, brightness=65535, kelvin=3500)
108
+ green = LightHsbk(hue=21845, saturation=65535, brightness=65535, kelvin=3500)
109
+
110
+ # Step 1: Update first 64 zones in framebuffer 1 (rows 0-3)
111
+ rect1 = TileBufferRect(fb_index=1, x=0, y=0, width=16)
112
+ set1 = Tile.Set64(
113
+ tile_index=0,
114
+ rect=rect1,
115
+ duration=0,
116
+ colors=[red] * 64
117
+ )
118
+ device.process_packet(header, set1)
119
+
120
+ # Step 2: Update next 64 zones in framebuffer 1 (rows 4-7)
121
+ rect2 = TileBufferRect(fb_index=1, x=0, y=4, width=16)
122
+ set2 = Tile.Set64(
123
+ tile_index=0,
124
+ rect=rect2,
125
+ duration=0,
126
+ colors=[green] * 64
127
+ )
128
+ device.process_packet(header, set2)
129
+
130
+ # Step 3: Atomically display all 128 zones
131
+ copy = Tile.CopyFrameBuffer(
132
+ tile_index=0,
133
+ src_fb_index=1,
134
+ dst_fb_index=0,
135
+ src_x=0,
136
+ src_y=0,
137
+ dst_x=0,
138
+ dst_y=0,
139
+ width=16,
140
+ height=8,
141
+ duration=0
142
+ )
143
+ device.process_packet(header, copy)
144
+
145
+ # All 128 zones now visible without flicker
146
+ ```
147
+
148
+ ## Implementation Details
149
+
150
+ ### Storage
151
+
152
+ - **Framebuffer 0**: Stored in `tile_devices[i]["colors"]` (protocol-defined)
153
+ - **Framebuffers 1-7**: Stored in `MatrixState.tile_framebuffers` (internal)
154
+
155
+ ### Lazy Initialization
156
+
157
+ Non-visible framebuffers are created on first access:
158
+
159
+ ```python
160
+ # First Set64 to framebuffer 2 creates it automatically
161
+ # Initialized with black (hue=0, saturation=0, brightness=0)
162
+ ```
163
+
164
+ ### Persistence
165
+
166
+ Non-visible framebuffers are saved with device state when persistence is enabled:
167
+
168
+ ```bash
169
+ lifx-emulator --persistent --tile 1
170
+ ```
171
+
172
+ ## Best Practices
173
+
174
+ ### For Tiles ≤64 Zones
175
+ Update framebuffer 0 directly (no need for off-screen preparation):
176
+
177
+ ```python
178
+ rect = TileBufferRect(fb_index=0, x=0, y=0, width=8)
179
+ ```
180
+
181
+ ### For Tiles >64 Zones
182
+ Always use a non-visible framebuffer:
183
+
184
+ 1. Prepare all zones in framebuffer 1-7
185
+ 2. Use CopyFrameBuffer to make visible
186
+ 3. Prevents flicker during multi-packet updates
187
+
188
+ ### Partial Updates
189
+ Use CopyFrameBuffer with specific rectangles:
190
+
191
+ ```python
192
+ # Copy only top-left 4×4 area
193
+ copy = Tile.CopyFrameBuffer(
194
+ src_fb_index=1,
195
+ dst_fb_index=0,
196
+ src_x=0,
197
+ src_y=0,
198
+ dst_x=0,
199
+ dst_y=0,
200
+ width=4,
201
+ height=4,
202
+ duration=0
203
+ )
204
+ ```
205
+
206
+ ## Related Documentation
207
+
208
+ - [Device Types](device-types.md#matrix-devices) - Matrix device capabilities
209
+ - [Protocol](../architecture/protocol.md) - LIFX LAN protocol details
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-emulator"
3
- version = "2.2.1"
3
+ version = "2.3.0"
4
4
  description = "LIFX Emulator for testing LIFX LAN protocol libraries"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -88,6 +88,7 @@ max-statements = 50
88
88
  [tool.ruff.lint.per-file-ignores]
89
89
  "src/lifx_emulator/__main__.py" = ["C901"]
90
90
  "src/lifx_emulator/devices/state_restorer.py" = ["C901"]
91
+ "src/lifx_emulator/handlers/tile_handlers.py" = ["C901"]
91
92
  "src/lifx_emulator/protocol/generator.py" = ["E501", "C901"]
92
93
  "src/lifx_emulator/protocol/packets.py" = ["E501"]
93
94
  "src/lifx_emulator/products/registry.py" = ["C901"]
@@ -9,7 +9,7 @@ import time
9
9
  from typing import Any
10
10
 
11
11
  from lifx_emulator.constants import LIFX_HEADER_SIZE
12
- from lifx_emulator.devices.states import DeviceState
12
+ from lifx_emulator.devices.states import DeviceState, TileFramebuffers
13
13
  from lifx_emulator.handlers import HandlerRegistry, create_default_registry
14
14
  from lifx_emulator.protocol.header import LifxHeader
15
15
  from lifx_emulator.protocol.packets import (
@@ -114,6 +114,12 @@ class EmulatedLifxDevice:
114
114
  }
115
115
  )
116
116
 
117
+ # Initialize framebuffer storage for each tile (framebuffers 1-7)
118
+ # Framebuffer 0 is stored in tile_devices[i]["colors"]
119
+ if not self.state.tile_framebuffers:
120
+ for i in range(self.state.tile_count):
121
+ self.state.tile_framebuffers.append(TileFramebuffers(tile_index=i))
122
+
117
123
  # Save initial state if persistence is enabled
118
124
  # This ensures newly created devices are immediately persisted
119
125
  if self.storage:
@@ -97,6 +97,17 @@ def serialize_device_state(device_state: Any) -> dict[str, Any]:
97
97
  }
98
98
  for t in device_state.tile_devices
99
99
  ]
100
+ # Serialize tile framebuffers (non-visible framebuffers 1-7)
101
+ state_dict["tile_framebuffers"] = [
102
+ {
103
+ "tile_index": fb.tile_index,
104
+ "framebuffers": {
105
+ str(fb_idx): [serialize_hsbk(c) for c in colors]
106
+ for fb_idx, colors in fb.framebuffers.items()
107
+ },
108
+ }
109
+ for fb in device_state.tile_framebuffers
110
+ ]
100
111
 
101
112
  return state_dict
102
113
 
@@ -127,4 +138,20 @@ def deserialize_device_state(state_dict: dict[str, Any]) -> dict[str, Any]:
127
138
  for tile_dict in state_dict["tile_devices"]:
128
139
  tile_dict["colors"] = [deserialize_hsbk(c) for c in tile_dict["colors"]]
129
140
 
141
+ # Deserialize tile framebuffers if present (for backwards compatibility)
142
+ if "tile_framebuffers" in state_dict:
143
+ from lifx_emulator.devices.states import TileFramebuffers
144
+
145
+ deserialized_fbs = []
146
+ for fb_dict in state_dict["tile_framebuffers"]:
147
+ tile_fb = TileFramebuffers(tile_index=fb_dict["tile_index"])
148
+ # Deserialize each framebuffer's colors
149
+ for fb_idx_str, colors_list in fb_dict["framebuffers"].items():
150
+ fb_idx = int(fb_idx_str)
151
+ tile_fb.framebuffers[fb_idx] = [
152
+ deserialize_hsbk(c) for c in colors_list
153
+ ]
154
+ deserialized_fbs.append(tile_fb)
155
+ state_dict["tile_framebuffers"] = deserialized_fbs
156
+
130
157
  return state_dict
@@ -82,6 +82,32 @@ class MultiZoneState:
82
82
  effect_speed: int = 5 # Duration of one cycle in seconds
83
83
 
84
84
 
85
+ @dataclass
86
+ class TileFramebuffers:
87
+ """Internal storage for non-visible tile framebuffers (1-7).
88
+
89
+ Framebuffer 0 is stored in tile_devices[i]["colors"] (the visible buffer).
90
+ Framebuffers 1-7 are stored here for Set64/CopyFrameBuffer operations.
91
+ Each framebuffer is a list of LightHsbk colors with length = width * height.
92
+ """
93
+
94
+ tile_index: int # Which tile this belongs to
95
+ framebuffers: dict[int, list[LightHsbk]] = field(default_factory=dict)
96
+
97
+ def get_framebuffer(
98
+ self, fb_index: int, width: int, height: int
99
+ ) -> list[LightHsbk]:
100
+ """Get framebuffer by index, creating it if needed."""
101
+ if fb_index not in self.framebuffers:
102
+ # Initialize with default black color
103
+ pixels = width * height
104
+ self.framebuffers[fb_index] = [
105
+ LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
106
+ for _ in range(pixels)
107
+ ]
108
+ return self.framebuffers[fb_index]
109
+
110
+
85
111
  @dataclass
86
112
  class MatrixState:
87
113
  """Matrix (tile/candle) capability state."""
@@ -101,6 +127,9 @@ class MatrixState:
101
127
  effect_cloud_sat_max: int = (
102
128
  0 # Max cloud saturation 0-200 (only when effect_type=5)
103
129
  )
130
+ # Internal storage for non-visible framebuffers (1-7) per tile
131
+ # Framebuffer 0 remains in tile_devices[i]["colors"]
132
+ tile_framebuffers: list[TileFramebuffers] = field(default_factory=list)
104
133
 
105
134
 
106
135
  @dataclass
@@ -215,6 +244,7 @@ class DeviceState:
215
244
  "tile_effect_sky_type": ("matrix", "effect_sky_type"),
216
245
  "tile_effect_cloud_sat_min": ("matrix", "effect_cloud_sat_min"),
217
246
  "tile_effect_cloud_sat_max": ("matrix", "effect_cloud_sat_max"),
247
+ "tile_framebuffers": "matrix",
218
248
  }
219
249
 
220
250
  # Default values for optional state attributes when state object is None
@@ -240,6 +270,7 @@ class DeviceState:
240
270
  "tile_effect_sky_type": 0,
241
271
  "tile_effect_cloud_sat_min": 0,
242
272
  "tile_effect_cloud_sat_max": 0,
273
+ "tile_framebuffers": [],
243
274
  }
244
275
 
245
276
  def get_target_bytes(self) -> bytes:
@@ -12,6 +12,7 @@ from lifx_emulator.protocol.protocol_types import (
12
12
  DeviceStateVersion,
13
13
  LightHsbk,
14
14
  TileAccelMeas,
15
+ TileBufferRect,
15
16
  TileEffectParameter,
16
17
  TileEffectSettings,
17
18
  TileEffectType,
@@ -137,6 +138,10 @@ class Get64Handler(PacketHandler):
137
138
  tile_width = tile["width"]
138
139
  tile_height = tile["height"]
139
140
 
141
+ # Get64 always returns framebuffer 0 (the visible buffer)
142
+ # regardless of which fb_index is in the request
143
+ tile_colors = tile["colors"]
144
+
140
145
  # Calculate how many rows fit in 64 pixels
141
146
  rows_to_return = 64 // rect.width if rect.width > 0 else 1
142
147
  rows_to_return = min(rows_to_return, tile_height - rect.y)
@@ -161,8 +166,8 @@ class Get64Handler(PacketHandler):
161
166
 
162
167
  # Calculate pixel index in flat color array
163
168
  pixel_idx = y * tile_width + x
164
- if pixel_idx < len(tile["colors"]):
165
- colors.append(tile["colors"][pixel_idx])
169
+ if pixel_idx < len(tile_colors):
170
+ colors.append(tile_colors[pixel_idx])
166
171
  else:
167
172
  colors.append(
168
173
  LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500)
@@ -173,7 +178,14 @@ class Get64Handler(PacketHandler):
173
178
  while len(colors) < 64:
174
179
  colors.append(LightHsbk(hue=0, saturation=0, brightness=0, kelvin=3500))
175
180
 
176
- return [Tile.State64(tile_index=tile_index, rect=rect, colors=colors)]
181
+ # Return with fb_index forced to 0 (visible buffer)
182
+ return_rect = TileBufferRect(
183
+ fb_index=0, # Always return FB0
184
+ x=rect.x,
185
+ y=rect.y,
186
+ width=rect.width,
187
+ )
188
+ return [Tile.State64(tile_index=tile_index, rect=return_rect, colors=colors)]
177
189
 
178
190
 
179
191
  class Set64Handler(PacketHandler):
@@ -188,16 +200,60 @@ class Set64Handler(PacketHandler):
188
200
  return []
189
201
 
190
202
  tile_index = packet.tile_index
203
+ fb_index = packet.rect.fb_index
191
204
 
192
- if tile_index < len(device_state.tile_devices):
193
- # Update colors from packet
194
- for i, color in enumerate(packet.colors[:64]):
195
- if i < len(device_state.tile_devices[tile_index]["colors"]):
196
- device_state.tile_devices[tile_index]["colors"][i] = color
205
+ if tile_index >= len(device_state.tile_devices):
206
+ return []
197
207
 
198
- logger.info(
199
- f"Tile {tile_index} set 64 colors, duration={packet.duration}ms"
200
- )
208
+ tile = device_state.tile_devices[tile_index]
209
+ tile_width = tile["width"]
210
+ tile_height = tile["height"]
211
+ rect = packet.rect
212
+
213
+ # Determine which framebuffer to update
214
+ if fb_index == 0:
215
+ # Update visible framebuffer (stored in tile_devices)
216
+ target_colors = tile["colors"]
217
+ else:
218
+ # Update non-visible framebuffer (stored in tile_framebuffers)
219
+ if tile_index < len(device_state.tile_framebuffers):
220
+ fb_storage = device_state.tile_framebuffers[tile_index]
221
+ target_colors = fb_storage.get_framebuffer(
222
+ fb_index, tile_width, tile_height
223
+ )
224
+ else:
225
+ logger.warning(f"Tile {tile_index} framebuffer storage not initialized")
226
+ return []
227
+
228
+ # Update colors in the specified rectangle
229
+ # Calculate how many rows fit in 64 pixels
230
+ rows_to_write = 64 // rect.width if rect.width > 0 else 1
231
+ rows_to_write = min(rows_to_write, tile_height - rect.y)
232
+
233
+ pixels_written = 0
234
+ for row in range(rows_to_write):
235
+ y = rect.y + row
236
+ if y >= tile_height:
237
+ break
238
+
239
+ for col in range(rect.width):
240
+ x = rect.x + col
241
+ if x >= tile_width or pixels_written >= 64:
242
+ pixels_written += 1
243
+ continue
244
+
245
+ # Calculate pixel index in flat color array
246
+ pixel_idx = y * tile_width + x
247
+ if pixel_idx < len(target_colors) and pixels_written < len(
248
+ packet.colors
249
+ ):
250
+ target_colors[pixel_idx] = packet.colors[pixels_written]
251
+ pixels_written += 1
252
+
253
+ logger.info(
254
+ f"Tile {tile_index} FB{fb_index} set {pixels_written} colors at "
255
+ f"({rect.x},{rect.y}), duration={packet.duration}ms"
256
+ )
201
257
 
202
258
  # Tiles never return a response to Set64 regardless of res_required
203
259
  # https://lan.developer.lifx.com/docs/changing-a-device#set64---packet-715
@@ -212,12 +268,83 @@ class CopyFrameBufferHandler(PacketHandler):
212
268
  def handle(
213
269
  self, device_state: DeviceState, packet: Any | None, res_required: bool
214
270
  ) -> list[Any]:
215
- if not device_state.has_matrix:
271
+ if not device_state.has_matrix or not packet:
272
+ return []
273
+
274
+ tile_index = packet.tile_index
275
+ if tile_index >= len(device_state.tile_devices):
216
276
  return []
217
277
 
218
- logger.debug("Tile copy frame buffer command received (no-op in emulator)")
219
- # In a real device, this would copy the frame buffer to display
220
- # In emulator, we don't need to do anything special
278
+ tile = device_state.tile_devices[tile_index]
279
+ tile_width = tile["width"]
280
+ tile_height = tile["height"]
281
+
282
+ src_fb_index = packet.src_fb_index
283
+ dst_fb_index = packet.dst_fb_index
284
+
285
+ # Get source framebuffer
286
+ if src_fb_index == 0:
287
+ src_colors = tile["colors"]
288
+ else:
289
+ if tile_index < len(device_state.tile_framebuffers):
290
+ fb_storage = device_state.tile_framebuffers[tile_index]
291
+ src_colors = fb_storage.get_framebuffer(
292
+ src_fb_index, tile_width, tile_height
293
+ )
294
+ else:
295
+ logger.warning(f"Tile {tile_index} framebuffer storage not initialized")
296
+ return []
297
+
298
+ # Get destination framebuffer
299
+ if dst_fb_index == 0:
300
+ dst_colors = tile["colors"]
301
+ else:
302
+ if tile_index < len(device_state.tile_framebuffers):
303
+ fb_storage = device_state.tile_framebuffers[tile_index]
304
+ dst_colors = fb_storage.get_framebuffer(
305
+ dst_fb_index, tile_width, tile_height
306
+ )
307
+ else:
308
+ logger.warning(f"Tile {tile_index} framebuffer storage not initialized")
309
+ return []
310
+
311
+ # Copy the specified rectangle from source to destination
312
+ src_x = packet.src_x
313
+ src_y = packet.src_y
314
+ dst_x = packet.dst_x
315
+ dst_y = packet.dst_y
316
+ width = packet.width
317
+ height = packet.height
318
+
319
+ pixels_copied = 0
320
+ for row in range(height):
321
+ src_row = src_y + row
322
+ dst_row = dst_y + row
323
+
324
+ if src_row >= tile_height or dst_row >= tile_height:
325
+ break
326
+
327
+ for col in range(width):
328
+ src_col = src_x + col
329
+ dst_col = dst_x + col
330
+
331
+ if src_col >= tile_width or dst_col >= tile_width:
332
+ continue
333
+
334
+ src_idx = src_row * tile_width + src_col
335
+ dst_idx = dst_row * tile_width + dst_col
336
+
337
+ if src_idx < len(src_colors) and dst_idx < len(dst_colors):
338
+ dst_colors[dst_idx] = src_colors[src_idx]
339
+ pixels_copied += 1
340
+
341
+ logger.info(
342
+ f"Tile {tile_index} copied {pixels_copied} pixels from "
343
+ f"FB{src_fb_index}({src_x},{src_y}) to "
344
+ f"FB{dst_fb_index}({dst_x},{dst_y}), "
345
+ f"size={width}x{height}, duration={packet.duration}ms"
346
+ )
347
+
221
348
  return []
222
349
 
223
350