lifx-emulator 2.3.1__tar.gz → 2.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 (139) hide show
  1. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/CLAUDE.md +19 -2
  2. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/PKG-INFO +1 -1
  3. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/changelog.md +8 -0
  4. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/getting-started/cli.md +12 -2
  5. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/guide/device-types.md +76 -0
  6. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/pyproject.toml +1 -1
  7. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/__main__.py +11 -2
  8. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/devices/device.py +56 -0
  9. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/devices/states.py +4 -0
  10. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/factories/__init__.py +2 -0
  11. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/factories/builder.py +2 -0
  12. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/factories/factory.py +31 -0
  13. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/products/generator.py +75 -33
  14. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/products/registry.py +46 -12
  15. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/products/specs.yml +38 -4
  16. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_cli.py +4 -4
  17. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_products_generator.py +9 -10
  18. lifx_emulator-2.4.0/tests/test_switch_devices.py +335 -0
  19. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/uv.lock +1 -1
  20. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/.github/workflows/ci.yml +0 -0
  21. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/.github/workflows/docs.yml +0 -0
  22. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/.gitignore +0 -0
  23. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/.pre-commit-config.yaml +0 -0
  24. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/LICENSE +0 -0
  25. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/README.md +0 -0
  26. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/advanced/device-management-api.md +0 -0
  27. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/advanced/index.md +0 -0
  28. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/advanced/scenario-api.md +0 -0
  29. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/advanced/scenarios.md +0 -0
  30. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/advanced/storage.md +0 -0
  31. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/api/device.md +0 -0
  32. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/api/factories.md +0 -0
  33. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/api/index.md +0 -0
  34. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/api/products.md +0 -0
  35. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/api/protocol.md +0 -0
  36. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/api/server.md +0 -0
  37. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/api/storage.md +0 -0
  38. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/architecture/device-state.md +0 -0
  39. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/architecture/index.md +0 -0
  40. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/architecture/overview.md +0 -0
  41. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/architecture/packet-flow.md +0 -0
  42. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/architecture/protocol.md +0 -0
  43. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/assets/favicon.png +0 -0
  44. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/faq.md +0 -0
  45. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/getting-started/index.md +0 -0
  46. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/getting-started/installation.md +0 -0
  47. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/getting-started/quickstart.md +0 -0
  48. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/guide/best-practices.md +0 -0
  49. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/guide/framebuffers.md +0 -0
  50. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/guide/index.md +0 -0
  51. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/guide/integration-testing.md +0 -0
  52. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/guide/products-and-specs.md +0 -0
  53. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/guide/testing-scenarios.md +0 -0
  54. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/guide/web-interface.md +0 -0
  55. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/index.md +0 -0
  56. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/reference/glossary.md +0 -0
  57. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/reference/troubleshooting.md +0 -0
  58. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/stylesheets/extra.css +0 -0
  59. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/tutorials/01-first-device.md +0 -0
  60. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/tutorials/02-basic.md +0 -0
  61. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/tutorials/03-integration.md +0 -0
  62. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/tutorials/04-advanced-scenarios.md +0 -0
  63. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/tutorials/05-cicd.md +0 -0
  64. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/docs/tutorials/index.md +0 -0
  65. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/mkdocs.yml +0 -0
  66. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/renovate.json +0 -0
  67. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/__init__.py +0 -0
  68. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/__init__.py +0 -0
  69. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/app.py +0 -0
  70. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/mappers/__init__.py +0 -0
  71. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/mappers/device_mapper.py +0 -0
  72. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/models.py +0 -0
  73. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/routers/__init__.py +0 -0
  74. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/routers/devices.py +0 -0
  75. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/routers/monitoring.py +0 -0
  76. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/routers/scenarios.py +0 -0
  77. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/services/__init__.py +0 -0
  78. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/services/device_service.py +0 -0
  79. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/api/templates/dashboard.html +0 -0
  80. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/constants.py +0 -0
  81. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/devices/__init__.py +0 -0
  82. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/devices/manager.py +0 -0
  83. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/devices/observers.py +0 -0
  84. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/devices/persistence.py +0 -0
  85. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/devices/state_restorer.py +0 -0
  86. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/devices/state_serializer.py +0 -0
  87. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/factories/default_config.py +0 -0
  88. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/factories/firmware_config.py +0 -0
  89. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/factories/serial_generator.py +0 -0
  90. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/handlers/__init__.py +0 -0
  91. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/handlers/base.py +0 -0
  92. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/handlers/device_handlers.py +0 -0
  93. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/handlers/light_handlers.py +0 -0
  94. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/handlers/multizone_handlers.py +0 -0
  95. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/handlers/registry.py +0 -0
  96. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/handlers/tile_handlers.py +0 -0
  97. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/products/__init__.py +0 -0
  98. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/products/specs.py +0 -0
  99. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/protocol/__init__.py +0 -0
  100. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/protocol/base.py +0 -0
  101. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/protocol/const.py +0 -0
  102. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/protocol/generator.py +0 -0
  103. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/protocol/header.py +0 -0
  104. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/protocol/packets.py +0 -0
  105. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/protocol/protocol_types.py +0 -0
  106. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/protocol/serializer.py +0 -0
  107. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/repositories/__init__.py +0 -0
  108. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/repositories/device_repository.py +0 -0
  109. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/repositories/storage_backend.py +0 -0
  110. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/scenarios/__init__.py +0 -0
  111. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/scenarios/manager.py +0 -0
  112. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/scenarios/models.py +0 -0
  113. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/scenarios/persistence.py +0 -0
  114. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/src/lifx_emulator/server.py +0 -0
  115. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/conftest.py +0 -0
  116. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_api.py +0 -0
  117. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_api_validation.py +0 -0
  118. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_async_storage.py +0 -0
  119. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_backwards_compatibility.py +0 -0
  120. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_cli_validation.py +0 -0
  121. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_device.py +0 -0
  122. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_device_edge_cases.py +0 -0
  123. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_device_handlers_extended.py +0 -0
  124. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_device_manager.py +0 -0
  125. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_handler_registry.py +0 -0
  126. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_integration.py +0 -0
  127. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_light_handlers_extended.py +0 -0
  128. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_multizone_handlers_extended.py +0 -0
  129. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_observers.py +0 -0
  130. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_products_specs.py +0 -0
  131. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_protocol_generator.py +0 -0
  132. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_protocol_types_coverage.py +0 -0
  133. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_repositories.py +0 -0
  134. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_scenario_manager.py +0 -0
  135. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_scenario_persistence.py +0 -0
  136. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_serializer.py +0 -0
  137. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_server.py +0 -0
  138. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_state_restorer.py +0 -0
  139. {lifx_emulator-2.3.1 → lifx_emulator-2.4.0}/tests/test_tile_handlers_extended.py +0 -0
@@ -70,7 +70,7 @@ lifx-emulator --bind 192.168.1.100 --port 56700
70
70
  lifx-emulator --color 2 --multizone 1 --tile 1 --verbose
71
71
 
72
72
  # Create only specific device types
73
- lifx-emulator --color 0 --infrared 3 --hev 2
73
+ lifx-emulator --color 0 --infrared 3 --hev 2 --switch 2
74
74
 
75
75
  # Mix product IDs with device types
76
76
  lifx-emulator --product 27 --color 2 --multizone 1
@@ -130,6 +130,7 @@ lifx-emulator --help
130
130
  - `--tile-count`: Tiles per device (uses product default if not specified)
131
131
  - `--tile-width`: Width of each tile in zones (uses product default if not specified)
132
132
  - `--tile-height`: Height of each tile in zones (uses product default if not specified)
133
+ - `--switch`: Number of LIFX Switch devices (relays, no lighting, default: 0)
133
134
  - `--serial-prefix`: serial prefix (6 hex chars, default: d073d5)
134
135
  - `--serial-start`: Starting serial suffix (default: 1)
135
136
  - `--api`: Enable HTTP API server for monitoring and management (default: False)
@@ -521,7 +522,7 @@ delay = manager.get_response_delay(502, merged) # 1.0s
521
522
 
522
523
  **DeviceState** (`src/lifx_emulator/devices/states.py`):
523
524
  - Dataclass holding all device state (color, power, zones, tiles, firmware version, etc.)
524
- - Capability flags: `has_color`, `has_infrared`, `has_multizone`, `has_matrix`, `has_hev`
525
+ - Capability flags: `has_color`, `has_infrared`, `has_multizone`, `has_matrix`, `has_hev`, `has_relays`, `has_buttons`
525
526
  - Initialized differently per device type via factory functions
526
527
  - **TileFramebuffers**: Internal dataclass for storing non-visible framebuffers (1-7) per tile
527
528
  - Provides `get_framebuffer(fb_index, width, height)` for lazy initialization
@@ -562,6 +563,10 @@ delay = manager.get_response_delay(502, merged) # 1.0s
562
563
  - Zone count uses product defaults from specs if not specified
563
564
  - `create_tile_device(tile_count=None)`: Tile chain (product=55)
564
565
  - Tile count and dimensions use product defaults from specs if not specified
566
+ - `create_switch(product_id=70)`: LIFX Switch device (product=70)
567
+ - Has `has_relays=True` and `has_buttons=True` capabilities
568
+ - No lighting control (responds with StateUnhandled to Light/MultiZone/Tile packets)
569
+ - Supports all Device.* packets for basic device information
565
570
  - `create_device(product_id, zone_count=None, tile_count=None)`: Universal factory
566
571
  - Creates any device by product ID from the registry
567
572
  - Automatically uses product defaults from specs system
@@ -764,6 +769,18 @@ Matrix devices support 8 framebuffers (0-7) for advanced rendering:
764
769
  - Framebuffers are lazily initialized on first access
765
770
  - Non-visible framebuffers are persisted with device state
766
771
 
772
+ ### Switch Handling
773
+ - LIFX Switch devices have `has_relays=True` and `has_buttons=True` capabilities
774
+ - Switches do not support lighting operations (no color, brightness, or zone control)
775
+ - **Capability-based packet filtering**: Switches automatically return `StateUnhandled` (packet 223) for:
776
+ - Light.* packets (types 101-149): GetColor, SetColor, SetWaveform, etc.
777
+ - MultiZone.* packets (types 501-512): GetColorZones, SetColorZones, etc.
778
+ - Tile.* packets (types 701-720): Get64, Set64, GetTileEffect, etc.
779
+ - Switches handle Device.* packets (types 2-59) normally: GetVersion, GetLabel, EchoRequest, etc.
780
+ - StateUnhandled response includes the `unhandled_type` field indicating which packet type was rejected
781
+ - Acknowledgments (packet 45) are still sent if `ack_required=True` flag is set
782
+ - **Note**: Button and relay protocol packets are not currently implemented (requires cloud/Matter infrastructure)
783
+
767
784
  ### Testing Scenarios
768
785
  Configure via ScenarioConfig in HierarchicalScenarioManager:
769
786
  - `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.3.1
3
+ Version: 2.4.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.4.0 (2025-11-19)
6
+
7
+ ### Features
8
+
9
+ - Implement LIFX Switch device emulation with StateUnhandled responses
10
+ ([`e2f9114`](https://github.com/Djelibeybi/lifx-emulator/commit/e2f911420db1d27d916247a9b3fdb50f31276b48))
11
+
12
+
5
13
  ## v2.3.1 (2025-11-18)
6
14
 
7
15
  ### Bug Fixes
@@ -211,6 +211,16 @@ Height of each tile in zones. If not specified, uses product default (typically
211
211
  - **Default:** `None` (uses product defaults)
212
212
  - **Example:** `--tile-height 8`
213
213
 
214
+ ### `--switch <COUNT>`
215
+
216
+ Number of LIFX Switch devices to emulate (relay-based switches with no lighting).
217
+
218
+ - **Default:** `0`
219
+ - **Product:** 70 (LIFX Switch)
220
+ - **Example:** `--switch 2`
221
+
222
+ Switch devices have `has_relays=True` and `has_buttons=True` capabilities but do not support Light, MultiZone, or Tile protocol packets. They respond with `StateUnhandled` (packet 223) to unsupported requests.
223
+
214
224
  ## serial Options
215
225
 
216
226
  ### `--serial-prefix <PREFIX>`
@@ -291,8 +301,8 @@ lifx-emulator --serial-prefix cafe00 --serial-start 100 --color 3
291
301
  ### Only Specific Types
292
302
 
293
303
  ```bash
294
- # No default devices, only infrared and HEV
295
- lifx-emulator --color 0 --infrared 3 --HEV 2
304
+ # No default devices, only infrared, HEV, and switches
305
+ lifx-emulator --color 0 --infrared 3 --HEV 2 --switch 2
296
306
  ```
297
307
 
298
308
  ### Discovery Testing
@@ -341,6 +341,82 @@ For large tiles (>64 zones), prepare all zones in a non-visible framebuffer, the
341
341
  See [Framebuffer Guide](framebuffers.md) for complete documentation and examples.
342
342
 
343
343
 
344
+ ## Switch Devices
345
+
346
+ LIFX Switch devices are relay-based switches with no lighting capabilities. They respond with `StateUnhandled` (packet 223) to all lighting-related protocol requests.
347
+
348
+ ### Example Products
349
+
350
+ - **LIFX Switch** (product IDs 70, 71, 89, 115, 116) - 2 relay switches
351
+
352
+ ### Capabilities
353
+
354
+ - **Relays**: Physical relay switches for controlling external loads
355
+ - **Buttons**: Physical buttons for manual control
356
+ - **No lighting**: No color, brightness, or zone control
357
+ - Basic device operations (GetVersion, GetLabel, EchoRequest, etc.)
358
+
359
+ ### Factory Function
360
+
361
+ ```python
362
+ from lifx_emulator import create_switch
363
+
364
+ # Create LIFX Switch (default product 70)
365
+ switch = create_switch("d073d7000001")
366
+
367
+ # Or specify a different switch product
368
+ switch = create_switch("d073d7000002", product_id=89)
369
+ ```
370
+
371
+ ### Switch Behavior
372
+
373
+ ```python
374
+ switch = create_switch("d073d7000001")
375
+
376
+ # Check capabilities
377
+ print(f"Has relays: {switch.state.has_relays}") # True
378
+ print(f"Has buttons: {switch.state.has_buttons}") # True
379
+ print(f"Has color: {switch.state.has_color}") # False
380
+ print(f"Has multizone: {switch.state.has_multizone}") # False
381
+ ```
382
+
383
+ ### Packet Handling
384
+
385
+ **Supported (Device.* packets 2-59):**
386
+ - `GetVersion` (32) → `StateVersion` (33)
387
+ - `GetLabel` (23) → `StateLabel` (25)
388
+ - `SetLabel` (24)
389
+ - `EchoRequest` (58) → `EchoResponse` (59)
390
+ - All other Device.* packets
391
+
392
+ **Rejected with StateUnhandled (223):**
393
+ - **Light.* packets (101-149)**: GetColor, SetColor, GetPower, SetPower, etc.
394
+ - **MultiZone.* packets (501-512)**: GetColorZones, SetColorZones, etc.
395
+ - **Tile.* packets (701-720)**: Get64, Set64, GetTileEffect, etc.
396
+
397
+ ### StateUnhandled Response
398
+
399
+ When a switch receives an unsupported packet type, it responds with:
400
+
401
+ ```python
402
+ # Client sends Light.GetColor (101) to switch
403
+ # Switch responds with:
404
+ # - StateUnhandled (223) with unhandled_type=101
405
+ # - Acknowledgement (45) if ack_required=True
406
+ ```
407
+
408
+ The `StateUnhandled` packet includes the rejected packet type in the `unhandled_type` field, allowing clients to detect and handle unsupported operations gracefully.
409
+
410
+ ### Limitations
411
+
412
+ **Note**: Button and relay control protocol packets are not currently implemented in the emulator.
413
+
414
+ The switch emulation is primarily for testing client libraries' handling of:
415
+ - Device capability detection
416
+ - StateUnhandled response handling
417
+ - Graceful degradation when lighting features are unavailable
418
+
419
+
344
420
  ## Using Generic create_device()
345
421
 
346
422
  All factory functions use `create_device()` internally. You can use it directly:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lifx-emulator"
3
- version = "2.3.1"
3
+ version = "2.4.0"
4
4
  description = "LIFX Emulator for testing LIFX LAN protocol libraries"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -21,6 +21,7 @@ from lifx_emulator.factories import (
21
21
  create_hev_light,
22
22
  create_infrared_light,
23
23
  create_multizone_light,
24
+ create_switch,
24
25
  create_tile_device,
25
26
  )
26
27
  from lifx_emulator.products.registry import get_registry
@@ -239,6 +240,7 @@ async def run(
239
240
  hev: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
240
241
  multizone: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
241
242
  tile: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
243
+ switch: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
242
244
  # Multizone Options
243
245
  multizone_zones: Annotated[
244
246
  int | None, cyclopts.Parameter(group=multizone_group)
@@ -284,6 +286,7 @@ async def run(
284
286
  multizone_extended: Enable extended multizone support (Beam).
285
287
  Set --no-multizone-extended for basic multizone (Z) devices.
286
288
  tile: Number of tile/matrix chain devices.
289
+ switch: Number of LIFX Switch devices (relays, no lighting).
287
290
  tile_count: Number of tiles per device. Uses product defaults if not
288
291
  specified (5 for Tile, 1 for Candle/Ceiling).
289
292
  tile_width: Width of each tile in zones. Uses product defaults if not
@@ -310,7 +313,7 @@ async def run(
310
313
  lifx-emulator --color 2 --multizone 1 --tile 1 --api --verbose
311
314
 
312
315
  Create only specific device types:
313
- lifx-emulator --color 0 --infrared 3 --hev 2
316
+ lifx-emulator --color 0 --infrared 3 --hev 2 --switch 2
314
317
 
315
318
  Custom serial prefix:
316
319
  lifx-emulator --serial-prefix cafe00 --color 5
@@ -410,6 +413,7 @@ async def run(
410
413
  and infrared == 0
411
414
  and hev == 0
412
415
  and multizone == 0
416
+ and switch == 0
413
417
  ):
414
418
  color = 0
415
419
 
@@ -423,6 +427,7 @@ async def run(
423
427
  and hev == 0
424
428
  and multizone == 0
425
429
  and tile == 0
430
+ and switch == 0
426
431
  ):
427
432
  color = 0
428
433
 
@@ -467,13 +472,17 @@ async def run(
467
472
  )
468
473
  )
469
474
 
475
+ # Create switch devices
476
+ for _ in range(switch):
477
+ devices.append(create_switch(get_serial(), storage=storage))
478
+
470
479
  if not devices:
471
480
  if persistent:
472
481
  logger.warning("No devices configured. Server will run with no devices.")
473
482
  logger.info("Use API (--api) or restart with device flags to add devices.")
474
483
  else:
475
484
  logger.error(
476
- "No devices configured. Use --color, --multizone, --tile, "
485
+ "No devices configured. Use --color, --multizone, --tile, --switch, "
477
486
  "etc. to add devices."
478
487
  )
479
488
  return
@@ -215,6 +215,35 @@ class EmulatedLifxDevice:
215
215
  header.size = LIFX_HEADER_SIZE + payload_size
216
216
  return header
217
217
 
218
+ def _should_handle_packet(self, pkt_type: int) -> bool:
219
+ """Check if device should handle a packet type based on capabilities.
220
+
221
+ Args:
222
+ pkt_type: Packet type number
223
+
224
+ Returns:
225
+ True if device should handle, False if should return StateUnhandled
226
+ """
227
+ # Device.* packets are always handled (2-59)
228
+ if 2 <= pkt_type <= 59:
229
+ return True
230
+
231
+ # Light.* packets (101-149) require light capabilities
232
+ # Switches (devices with relays) don't support light operations
233
+ if 101 <= pkt_type <= 149:
234
+ return not self.state.has_relays
235
+
236
+ # MultiZone.* packets (501-512) require multizone capability
237
+ if 501 <= pkt_type <= 512:
238
+ return self.state.has_multizone
239
+
240
+ # Tile.* packets (701-720) require matrix capability
241
+ if 701 <= pkt_type <= 720:
242
+ return self.state.has_matrix
243
+
244
+ # Unknown packets - let handler decide
245
+ return True
246
+
218
247
  def process_packet(
219
248
  self, header: LifxHeader, packet: Any | None
220
249
  ) -> list[tuple[LifxHeader, Any]]:
@@ -229,6 +258,33 @@ class EmulatedLifxDevice:
229
258
  logger.info("Dropping packet type %s per scenario", header.pkt_type)
230
259
  return responses
231
260
 
261
+ # Check if device should handle this packet type (capability-based filtering)
262
+ if not self._should_handle_packet(header.pkt_type):
263
+ # Return StateUnhandled for unsupported packet types
264
+ state_unhandled = Device.StateUnhandled(unhandled_type=header.pkt_type)
265
+ unhandled_payload = state_unhandled.pack()
266
+ unhandled_header = self._create_response_header(
267
+ header.source,
268
+ header.sequence,
269
+ state_unhandled.PKT_TYPE,
270
+ len(unhandled_payload),
271
+ )
272
+ responses.append((unhandled_header, state_unhandled))
273
+
274
+ # Still send acknowledgment if requested
275
+ if header.ack_required:
276
+ ack_packet = Device.Acknowledgement()
277
+ ack_payload = ack_packet.pack()
278
+ ack_header = self._create_response_header(
279
+ header.source,
280
+ header.sequence,
281
+ ack_packet.PKT_TYPE,
282
+ len(ack_payload),
283
+ )
284
+ responses.append((ack_header, ack_packet))
285
+
286
+ return responses
287
+
232
288
  # Update uptime
233
289
  self.state.uptime_ns = self.get_uptime_ns()
234
290
 
@@ -183,6 +183,8 @@ class DeviceState:
183
183
  has_extended_multizone: bool = False
184
184
  has_matrix: bool = False
185
185
  has_hev: bool = False
186
+ has_relays: bool = False
187
+ has_buttons: bool = False
186
188
 
187
189
  # Attribute routing map: maps attribute prefixes to state objects
188
190
  # This eliminates ~360 lines of property boilerplate
@@ -347,6 +349,8 @@ class DeviceState:
347
349
  "has_extended_multizone",
348
350
  "has_matrix",
349
351
  "has_hev",
352
+ "has_relays",
353
+ "has_buttons",
350
354
  } or name.startswith("_"):
351
355
  object.__setattr__(self, name, value)
352
356
  return
@@ -15,6 +15,7 @@ from lifx_emulator.factories.factory import (
15
15
  create_hev_light,
16
16
  create_infrared_light,
17
17
  create_multizone_light,
18
+ create_switch,
18
19
  create_tile_device,
19
20
  )
20
21
  from lifx_emulator.factories.firmware_config import FirmwareConfig
@@ -32,6 +33,7 @@ __all__ = [
32
33
  "create_infrared_light",
33
34
  "create_hev_light",
34
35
  "create_multizone_light",
36
+ "create_switch",
35
37
  "create_tile_device",
36
38
  "create_color_temperature_light",
37
39
  ]
@@ -257,6 +257,8 @@ class DeviceBuilder:
257
257
  has_extended_multizone=has_extended_multizone,
258
258
  has_matrix=self._product_info.has_matrix,
259
259
  has_hev=self._product_info.has_hev,
260
+ has_relays=self._product_info.has_relays,
261
+ has_buttons=self._product_info.has_buttons,
260
262
  )
261
263
 
262
264
  # 10. Restore saved state if persistence enabled
@@ -141,6 +141,37 @@ def create_color_temperature_light(
141
141
  ) # LIFX Mini White to Warm
142
142
 
143
143
 
144
+ def create_switch(
145
+ serial: str | None = None,
146
+ product_id: int = 70,
147
+ firmware_version: tuple[int, int] | None = None,
148
+ storage: DevicePersistenceAsyncFile | None = None,
149
+ scenario_manager: HierarchicalScenarioManager | None = None,
150
+ ) -> EmulatedLifxDevice:
151
+ """Create a LIFX Switch device.
152
+
153
+ Switches have has_relays and has_buttons capabilities but no lighting.
154
+ They respond with StateUnhandled (223) to Light, MultiZone, and Tile packets.
155
+
156
+ Args:
157
+ serial: Device serial number (auto-generated if None)
158
+ product_id: Switch product ID (default: 70 - LIFX Switch)
159
+ firmware_version: Optional firmware version (major, minor)
160
+ storage: Optional persistence backend
161
+ scenario_manager: Optional scenario manager for testing
162
+
163
+ Returns:
164
+ EmulatedLifxDevice configured as a switch
165
+ """
166
+ return create_device(
167
+ product_id,
168
+ serial=serial,
169
+ firmware_version=firmware_version,
170
+ storage=storage,
171
+ scenario_manager=scenario_manager,
172
+ )
173
+
174
+
144
175
  def create_device(
145
176
  product_id: int,
146
177
  serial: str | None = None,
@@ -174,7 +174,6 @@ def generate_product_definitions(
174
174
  code_lines.append("PRODUCTS: dict[int, ProductInfo] = {")
175
175
 
176
176
  product_count = 0
177
- skipped_count = 0
178
177
  for vendor_data in all_vendors:
179
178
  vendor_id = vendor_data.get("vid", 1)
180
179
  defaults = vendor_data.get("defaults", {})
@@ -186,11 +185,6 @@ def generate_product_definitions(
186
185
  name = product["name"]
187
186
  features = {**default_features, **product.get("features", {})}
188
187
 
189
- # Skip switch products (devices with relays) - these are not lights
190
- if features.get("relays"):
191
- skipped_count += 1
192
- continue
193
-
194
188
  # Build capabilities
195
189
  capabilities = _build_capabilities(features)
196
190
 
@@ -223,8 +217,6 @@ def generate_product_definitions(
223
217
  code_lines.append("")
224
218
 
225
219
  print(f"Generated {product_count} product definitions")
226
- if skipped_count > 0:
227
- print(f"Skipped {skipped_count} switch products (relays only)")
228
220
  return "\n".join(code_lines)
229
221
 
230
222
 
@@ -373,16 +365,17 @@ class ProductInfo:
373
365
  """Format product capabilities as a human-readable string.
374
366
 
375
367
  Returns:
376
- Comma-separated capability string (e.g., "full color, infrared, multizone")
368
+ Comma-separated capability string (e.g., "color, infrared, multizone")
377
369
  """
378
370
  caps = []
379
371
 
380
- # Determine base light type
372
+ if self.has_buttons:
373
+ caps.append("buttons")
374
+
381
375
  if self.has_relays:
382
- # Devices with relays are switches, not lights
383
- caps.append("switch")
376
+ caps.append("relays")
384
377
  elif self.has_color:
385
- caps.append("full color")
378
+ caps.append("color")
386
379
  else:
387
380
  # Check temperature range to determine white light type
388
381
  if self.temperature_range:
@@ -409,9 +402,7 @@ class ProductInfo:
409
402
  caps.append("HEV")
410
403
  if self.has_chain:
411
404
  caps.append("chain")
412
- if self.has_buttons and not self.has_relays:
413
- # Only show buttons if not already identified as switch
414
- caps.append("buttons")
405
+
415
406
 
416
407
  return ", ".join(caps) if caps else "unknown"
417
408
 
@@ -465,10 +456,6 @@ class ProductRegistry:
465
456
  prod_features = product.get("features", {})
466
457
  features: dict[str, Any] = {**default_features, **prod_features}
467
458
 
468
- # Skip switch products (devices with relays) - these are not lights
469
- if features.get("relays"):
470
- continue
471
-
472
459
  # Build capabilities bitfield
473
460
  capabilities = 0
474
461
  if features.get("color"):
@@ -667,7 +654,7 @@ def _discover_new_products(
667
654
  products_data: dict[str, Any] | list[dict[str, Any]],
668
655
  existing_specs: dict[int, dict[str, Any]],
669
656
  ) -> list[dict[str, Any]]:
670
- """Find new multizone or matrix products that need specs templates.
657
+ """Find new multizone, matrix, or switch products that need specs templates.
671
658
 
672
659
  Args:
673
660
  products_data: Parsed products.json data
@@ -691,21 +678,19 @@ def _discover_new_products(
691
678
  pid = product["pid"]
692
679
  features = {**default_features, **product.get("features", {})}
693
680
 
694
- # Skip switch products (devices with relays) - these are not lights
695
- if features.get("relays"):
696
- continue
697
-
698
681
  # Check if this product needs specs template
699
682
  if pid not in existing_specs:
700
683
  is_multizone = features.get("multizone", False)
701
684
  is_matrix = features.get("matrix", False)
685
+ is_switch = features.get("relays", False)
702
686
 
703
- if is_multizone or is_matrix:
687
+ if is_multizone or is_matrix or is_switch:
704
688
  new_product = {
705
689
  "pid": pid,
706
690
  "name": product["name"],
707
691
  "multizone": is_multizone,
708
692
  "matrix": is_matrix,
693
+ "switch": is_switch,
709
694
  "extended_multizone": False,
710
695
  }
711
696
 
@@ -732,7 +717,12 @@ def _add_product_templates(
732
717
  for product in new_products:
733
718
  product_name = product["name"].replace('"', '\\"')
734
719
 
735
- if product["multizone"]:
720
+ if product["switch"]:
721
+ existing_specs[product["pid"]] = {
722
+ "relay_count": 2,
723
+ "notes": product_name,
724
+ }
725
+ elif product["multizone"]:
736
726
  existing_specs[product["pid"]] = {
737
727
  "default_zone_count": 16,
738
728
  "min_zone_count": 1,
@@ -752,28 +742,32 @@ def _add_product_templates(
752
742
 
753
743
  def _categorize_products(
754
744
  existing_specs: dict[int, dict[str, Any]],
755
- ) -> tuple[list[int], list[int]]:
756
- """Categorize products into multizone and matrix.
745
+ ) -> tuple[list[int], list[int], list[int]]:
746
+ """Categorize products into switch, multizone, and matrix.
757
747
 
758
748
  Args:
759
749
  existing_specs: Product specs dictionary
760
750
 
761
751
  Returns:
762
- Tuple of (sorted_multizone_pids, sorted_matrix_pids)
752
+ Tuple of (sorted_switch_pids, sorted_multizone_pids, sorted_matrix_pids)
763
753
  """
754
+ switch_pids = []
764
755
  multizone_pids = []
765
756
  matrix_pids = []
766
757
 
767
758
  for pid, specs in existing_specs.items():
768
- if "tile_width" in specs or "tile_height" in specs:
759
+ if "relay_count" in specs:
760
+ switch_pids.append(pid)
761
+ elif "tile_width" in specs or "tile_height" in specs:
769
762
  matrix_pids.append(pid)
770
763
  elif "default_zone_count" in specs:
771
764
  multizone_pids.append(pid)
772
765
 
766
+ switch_pids.sort()
773
767
  multizone_pids.sort()
774
768
  matrix_pids.sort()
775
769
 
776
- return multizone_pids, matrix_pids
770
+ return switch_pids, multizone_pids, matrix_pids
777
771
 
778
772
 
779
773
  def _generate_yaml_header() -> list[str]:
@@ -833,6 +827,53 @@ def _generate_yaml_header() -> list[str]:
833
827
  ]
834
828
 
835
829
 
830
+ def _generate_switch_section(
831
+ switch_pids: list[int], existing_specs: dict[int, dict[str, Any]]
832
+ ) -> list[str]:
833
+ """Generate YAML lines for switch products section.
834
+
835
+ Args:
836
+ switch_pids: Sorted list of switch product IDs
837
+ existing_specs: Product specs dictionary
838
+
839
+ Returns:
840
+ List of YAML lines
841
+ """
842
+ if not switch_pids:
843
+ return []
844
+
845
+ lines = [
846
+ " # ========================================",
847
+ " # Switch Products (Relays)",
848
+ " # ========================================",
849
+ "",
850
+ ]
851
+
852
+ for pid in switch_pids:
853
+ specs = existing_specs[pid]
854
+ name = specs.get("notes", f"Product {pid}").split(" - ")[0]
855
+
856
+ lines.append(f" {pid}: # {name}")
857
+ lines.append(f" relay_count: {specs['relay_count']}")
858
+
859
+ # Add firmware version if present
860
+ if "default_firmware_major" in specs and "default_firmware_minor" in specs:
861
+ lines.append(
862
+ f" default_firmware_major: {specs['default_firmware_major']}"
863
+ )
864
+ lines.append(
865
+ f" default_firmware_minor: {specs['default_firmware_minor']}"
866
+ )
867
+
868
+ notes = specs.get("notes", "")
869
+ if notes:
870
+ notes_escaped = notes.replace('"', '\\"')
871
+ lines.append(f' notes: "{notes_escaped}"')
872
+ lines.append("")
873
+
874
+ return lines
875
+
876
+
836
877
  def _generate_multizone_section(
837
878
  multizone_pids: list[int], existing_specs: dict[int, dict[str, Any]]
838
879
  ) -> list[str]:
@@ -964,12 +1005,13 @@ def update_specs_file(
964
1005
  _add_product_templates(new_products, existing_specs)
965
1006
 
966
1007
  # Categorize products and sort
967
- multizone_pids, matrix_pids = _categorize_products(existing_specs)
1008
+ switch_pids, multizone_pids, matrix_pids = _categorize_products(existing_specs)
968
1009
 
969
1010
  # Build YAML content
970
1011
  lines = _generate_yaml_header()
971
1012
  lines.extend(_generate_multizone_section(multizone_pids, existing_specs))
972
1013
  lines.extend(_generate_matrix_section(matrix_pids, existing_specs))
1014
+ lines.extend(_generate_switch_section(switch_pids, existing_specs))
973
1015
 
974
1016
  # Write the new file
975
1017
  with open(specs_path, "w") as f: