lifx-emulator 4.0.0__py3-none-any.whl → 4.2.0__py3-none-any.whl

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 (42) hide show
  1. {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/METADATA +2 -1
  2. lifx_emulator-4.2.0.dist-info/RECORD +43 -0
  3. lifx_emulator_app/__main__.py +35 -7
  4. lifx_emulator_app/api/__init__.py +0 -4
  5. lifx_emulator_app/api/app.py +122 -16
  6. lifx_emulator_app/api/models.py +32 -1
  7. lifx_emulator_app/api/routers/__init__.py +5 -1
  8. lifx_emulator_app/api/routers/devices.py +64 -10
  9. lifx_emulator_app/api/routers/products.py +42 -0
  10. lifx_emulator_app/api/routers/scenarios.py +55 -52
  11. lifx_emulator_app/api/routers/websocket.py +70 -0
  12. lifx_emulator_app/api/services/__init__.py +21 -4
  13. lifx_emulator_app/api/services/device_service.py +188 -1
  14. lifx_emulator_app/api/services/event_bridge.py +234 -0
  15. lifx_emulator_app/api/services/scenario_service.py +153 -0
  16. lifx_emulator_app/api/services/websocket_manager.py +326 -0
  17. lifx_emulator_app/api/static/_app/env.js +1 -0
  18. lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css +1 -0
  19. lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css +1 -0
  20. lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js +1 -0
  21. lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js +1 -0
  22. lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js +2 -0
  23. lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js +1 -0
  24. lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js +1 -0
  25. lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js +1 -0
  26. lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js +1 -0
  27. lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js +1 -0
  28. lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js +1 -0
  29. lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js +2 -0
  30. lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js +1 -0
  31. lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js +1 -0
  32. lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js +1 -0
  33. lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js +6 -0
  34. lifx_emulator_app/api/static/_app/version.json +1 -0
  35. lifx_emulator_app/api/static/index.html +38 -0
  36. lifx_emulator_app/api/static/robots.txt +3 -0
  37. lifx_emulator_app/config.py +2 -0
  38. lifx_emulator-4.0.0.dist-info/RECORD +0 -20
  39. lifx_emulator_app/api/static/dashboard.js +0 -588
  40. lifx_emulator_app/api/templates/dashboard.html +0 -357
  41. {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
  42. {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lifx-emulator
3
- Version: 4.0.0
3
+ Version: 4.2.0
4
4
  Summary: Standalone LIFX Emulator with CLI and HTTP management API
5
5
  Author-email: Avi Miller <me@dje.li>
6
6
  Maintainer-email: Avi Miller <me@dje.li>
@@ -25,6 +25,7 @@ Requires-Dist: fastapi>=0.115.0
25
25
  Requires-Dist: lifx-emulator-core>=2.4.0
26
26
  Requires-Dist: rich>=14.2.0
27
27
  Requires-Dist: uvicorn>=0.34.0
28
+ Requires-Dist: websockets>=16.0
28
29
  Description-Content-Type: text/markdown
29
30
 
30
31
  # lifx-emulator
@@ -0,0 +1,43 @@
1
+ lifx_emulator_app/__init__.py,sha256=AJahGiWKb8U8yLQbJX21takbf-SoxDxMOGxjJeM7M5c,222
2
+ lifx_emulator_app/__main__.py,sha256=uVSlOQFWwkYLIXuMfyR7-rnyLxIrl5n5TKAfRlleaKg,42402
3
+ lifx_emulator_app/config.py,sha256=7NrcCEMTw5kPlFjcNrQ58k9aQlnQILs1EtCofE9-hV0,9606
4
+ lifx_emulator_app/api/__init__.py,sha256=bBOvo8V-jRrja_7EfFVEUwX3O8htH_iw6RjSzRsyZiM,478
5
+ lifx_emulator_app/api/app.py,sha256=utLERCLXL81S7iNKl5goRfleZlbH74LOz_A3IjX1jJ8,8539
6
+ lifx_emulator_app/api/models.py,sha256=Fb8LC_2UXryJ88u1UC6Ie1NDJWJtrKwX71z1WLw2tGQ,4890
7
+ lifx_emulator_app/api/mappers/__init__.py,sha256=-lGAg-s16eTMl2_D-3bPu-EMqD2kaPzXHqSrKCnmW2w,156
8
+ lifx_emulator_app/api/mappers/device_mapper.py,sha256=WAo-_PJ2kX3J4GUW_Sjoype1d_uaIh-PtXPfMUAujUY,4155
9
+ lifx_emulator_app/api/routers/__init__.py,sha256=wngfHz3BWSBhQDofegCy3VIhFazd-o3clVuACuW6wI0,592
10
+ lifx_emulator_app/api/routers/devices.py,sha256=VrAaWCO1Lrlgob1ffwMsVdAUqEPt1Q6HFf_clk9epxQ,6330
11
+ lifx_emulator_app/api/routers/monitoring.py,sha256=i82_s61caYd9UvMb4MqWPLP7LuFh5KN8Qkw4_dZr3O0,1460
12
+ lifx_emulator_app/api/routers/products.py,sha256=YJTIZ4UXqhy31OuKdGP41CEwxxKDv4WIKPkzFBdLrO4,1271
13
+ lifx_emulator_app/api/routers/scenarios.py,sha256=kUNcxxZbR_OMHVB-BM9znP7yKNNpPhCL6Qpcv3SFF_I,9521
14
+ lifx_emulator_app/api/routers/websocket.py,sha256=cyiUOkXT-YW7vgt0PJacOoREr5Oz8Yz928fCu4DmFFk,2019
15
+ lifx_emulator_app/api/services/__init__.py,sha256=e9GppGDarH26CASiOieXMV9QzKl5ynUzXqQU72B09wo,648
16
+ lifx_emulator_app/api/services/device_service.py,sha256=E1a-PQ1AKmOTmt6dx40MaLj5mnc_DNz4mG4eqeW4cM4,12932
17
+ lifx_emulator_app/api/services/event_bridge.py,sha256=oB34fM8XE2-ZJaUGOcrJKRsyj62LzBORgHmTE5yGn34,7843
18
+ lifx_emulator_app/api/services/scenario_service.py,sha256=zVBtBsf0nG-iGZLvPjQyIwil80tWk3ICx9fGsFLyFsE,5860
19
+ lifx_emulator_app/api/services/websocket_manager.py,sha256=JTgfYfo8jm_eoYPGA17aZ-dYg-DljfFWhEqnkS-c9gQ,10300
20
+ lifx_emulator_app/api/static/index.html,sha256=ZMiL4lm-Juc9kEZoNqsgeE40_kno0_nHhSjySWiPKeI,1291
21
+ lifx_emulator_app/api/static/robots.txt,sha256=sDMkMme8vauEywHpQIEdbXeolNePBHD6rw60P45O_Mk,63
22
+ lifx_emulator_app/api/static/_app/env.js,sha256=Iu6wbiiLCcW1n9HhTsju7Y83nmMPQC3Myj_B4dOvWXM,20
23
+ lifx_emulator_app/api/static/_app/version.json,sha256=I_nQhdqpDQFBXPoMM4XSrnpRi1VDFjX9tuqXRz5lPeE,28
24
+ lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css,sha256=l4crI2LGkKwPrBjfS2VhodMh_MO-Q1zVcXsKyymJh-k,6558
25
+ lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css,sha256=dFyY-aBNgdCqLldT_8KuedurSdWiJLlW5XYKE8GFq_A,6955
26
+ lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js,sha256=HF2RRzn5DD4c0A3xjueWbs0htVHr1g0XxTNcujwkmg0,523
27
+ lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js,sha256=1ruoxwIaTPdiiVHFhOPQ4S-ArwhXWWJYXUKLF49gMUM,375
28
+ lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js,sha256=g8YQ8djVfZiJP41wtJztMiMEirHHiAwxoecQ-PtQwbE,6150
29
+ lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js,sha256=JPl4dPNUoCEXC1bbzj3vbAhQBZTPdLtbEnpRYBY9sLg,1068
30
+ lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js,sha256=7bRXymbc4QDcpj3uBzyn6SsXz3L1z4Sxednc8KACn74,5511
31
+ lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js,sha256=jFLhx2UXRYkuldJjyM4ljyDDDs2tTo15BEoT-aut64k,21957
32
+ lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js,sha256=UtYJSUIlrSdrbWm9OQVo74bcDOFv8elKHuW_BMviWOQ,26068
33
+ lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js,sha256=QbqudTZYFirOclU5qWuqfStQOYHYur3TrSWf7qWdEEo,1528
34
+ lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js,sha256=cNaUEkLimf9FEBmEHfprdHsvcHKGCpNOfjZVEWe-TVg,370
35
+ lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js,sha256=GZuIPfD0v9P6HWVVUyAYR4PJBil21blmb7qRXieVauU,6687
36
+ lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js,sha256=bchZkS9By-K2qepPc-i5ULY0DT6RYceEM2cGA9K5a7A,83
37
+ lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js,sha256=9Un9WFVPvbSxbhGXFkk4sZWnTcDLtgXOulr1CDewG54,1093
38
+ lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js,sha256=72HaUFmedR2uD0gCxZy_a84lQRdEYUgRKP4imw5P0PY,556
39
+ lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js,sha256=yyGAY6GX7SYKRX9NNwgby_myabE5hpxCVUt1BM0aKkw,46908
40
+ lifx_emulator-4.2.0.dist-info/METADATA,sha256=TEvcyB8F3Ku6LBsKkDENONjHyJvBLdra8k2QsfHuBZU,3257
41
+ lifx_emulator-4.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
42
+ lifx_emulator-4.2.0.dist-info/entry_points.txt,sha256=tNZHeJTPUXNxu_nuk99ArXLKgwYLhIVVxN7YiaiXBOA,66
43
+ lifx_emulator-4.2.0.dist-info/RECORD,,
@@ -6,6 +6,7 @@ import logging
6
6
  import signal
7
7
  import uuid
8
8
  import warnings
9
+ import webbrowser
9
10
  from pathlib import Path
10
11
  from typing import Annotated
11
12
 
@@ -588,10 +589,20 @@ async def run(
588
589
  ] = None,
589
590
  # Storage & Persistence
590
591
  persistent: Annotated[
591
- bool | None, cyclopts.Parameter(negative="", group=storage_group)
592
+ bool | None,
593
+ cyclopts.Parameter(
594
+ negative="",
595
+ group=storage_group,
596
+ help="[DEPRECATED] Use a config file instead. See 'export-config'.",
597
+ ),
592
598
  ] = None,
593
599
  persistent_scenarios: Annotated[
594
- bool | None, cyclopts.Parameter(negative="", group=storage_group)
600
+ bool | None,
601
+ cyclopts.Parameter(
602
+ negative="",
603
+ group=storage_group,
604
+ help="[DEPRECATED] Use a config file instead. See 'export-config'.",
605
+ ),
595
606
  ] = None,
596
607
  # HTTP API Server
597
608
  api: Annotated[
@@ -600,6 +611,14 @@ async def run(
600
611
  api_host: Annotated[str | None, cyclopts.Parameter(group=api_group)] = None,
601
612
  api_port: Annotated[int | None, cyclopts.Parameter(group=api_group)] = None,
602
613
  api_activity: Annotated[bool | None, cyclopts.Parameter(group=api_group)] = None,
614
+ browser: Annotated[
615
+ bool | None,
616
+ cyclopts.Parameter(
617
+ negative="",
618
+ group=api_group,
619
+ help="Open dashboard in default browser when API starts.",
620
+ ),
621
+ ] = None,
603
622
  # Device Creation
604
623
  product: Annotated[
605
624
  list[int] | None, cyclopts.Parameter(negative_iterable="", group=device_group)
@@ -647,14 +666,17 @@ async def run(
647
666
  bind: IP address to bind to. Default: 127.0.0.1.
648
667
  port: UDP port to listen on. Default: 56700.
649
668
  verbose: Enable verbose logging showing all packets sent and received.
650
- persistent: Enable persistent storage of device state across restarts.
651
- persistent_scenarios: Enable persistent storage of test scenarios.
652
- Requires --persistent to be enabled.
669
+ persistent: DEPRECATED. Enable persistent storage of device state across
670
+ restarts. Use a config file instead. See 'export-config' command.
671
+ persistent_scenarios: DEPRECATED. Enable persistent storage of test
672
+ scenarios. Use a config file instead. See 'export-config' command.
653
673
  api: Enable HTTP API server for monitoring and runtime device management.
654
674
  api_host: API server host to bind to. Default: 127.0.0.1.
655
675
  api_port: API server port. Default: 8080.
656
676
  api_activity: Enable activity logging in API. Disable to reduce traffic
657
677
  and save UI space on the monitoring dashboard. Default: true.
678
+ browser: Open the monitoring dashboard in the default browser when the
679
+ API server starts. Requires --api. Default: false.
658
680
  product: Create devices by product ID. Can be specified multiple times.
659
681
  Run 'lifx-emulator list-products' to see available products.
660
682
  color: Number of full-color RGB lights to emulate.
@@ -701,8 +723,6 @@ async def run(
701
723
  Override a config file setting:
702
724
  lifx-emulator --config setup.yaml --port 56701
703
725
 
704
- Enable persistent storage:
705
- lifx-emulator --persistent --api
706
726
  """
707
727
  # Load and merge config file with CLI overrides
708
728
  cfg = _load_merged_config(
@@ -716,6 +736,7 @@ async def run(
716
736
  api_host=api_host,
717
737
  api_port=api_port,
718
738
  api_activity=api_activity,
739
+ browser=browser,
719
740
  products=product,
720
741
  color=color,
721
742
  color_temperature=color_temperature,
@@ -745,6 +766,7 @@ async def run(
745
766
  f_api_host: str = cfg["api_host"]
746
767
  f_api_port: int = cfg["api_port"]
747
768
  f_api_activity: bool = cfg["api_activity"]
769
+ f_browser: bool = cfg["browser"]
748
770
  f_products: list[int] | None = cfg["products"]
749
771
  f_color: int = cfg["color"]
750
772
  f_color_temperature: int = cfg["color_temperature"]
@@ -1065,6 +1087,12 @@ async def run(
1065
1087
  logger.info("Starting HTTP API server on http://%s:%s", f_api_host, f_api_port)
1066
1088
  api_task = asyncio.create_task(run_api_server(server, f_api_host, f_api_port))
1067
1089
 
1090
+ # Open browser if requested
1091
+ if f_browser:
1092
+ dashboard_url = f"http://{f_api_host}:{f_api_port}"
1093
+ logger.info("Opening dashboard in browser: %s", dashboard_url)
1094
+ webbrowser.open(dashboard_url)
1095
+
1068
1096
  # Set up graceful shutdown on signals
1069
1097
  shutdown_event = asyncio.Event()
1070
1098
  loop = asyncio.get_running_loop()
@@ -9,10 +9,6 @@ The API is built with FastAPI and organized into routers for clean separation
9
9
  of concerns.
10
10
  """
11
11
 
12
- # Import from new refactored structure
13
12
  from lifx_emulator_app.api.app import create_api_app, run_api_server
14
13
 
15
- # Note: HTML_UI remains in the old lifx_emulator/api.py file temporarily
16
- # TODO: Phase 1.1d - extract HTML template to separate file
17
-
18
14
  __all__ = ["create_api_app", "run_api_server"]
@@ -4,32 +4,40 @@ This module creates the main FastAPI application by assembling routers for:
4
4
  - Monitoring (server stats, activity)
5
5
  - Devices (CRUD operations)
6
6
  - Scenarios (test scenario management)
7
+ - WebSocket (real-time updates)
7
8
  """
8
9
 
9
10
  from __future__ import annotations
10
11
 
11
12
  import logging
13
+ from collections.abc import AsyncGenerator
14
+ from contextlib import asynccontextmanager
12
15
  from pathlib import Path
13
16
  from typing import TYPE_CHECKING
14
17
 
15
- from fastapi import FastAPI, Request
16
- from fastapi.responses import HTMLResponse
18
+ from fastapi import FastAPI
19
+ from fastapi.responses import FileResponse
17
20
  from fastapi.staticfiles import StaticFiles
18
- from fastapi.templating import Jinja2Templates
19
21
 
20
22
  if TYPE_CHECKING:
21
23
  from lifx_emulator.server import EmulatedLifxServer
22
24
 
23
25
  from lifx_emulator_app.api.routers.devices import create_devices_router
24
26
  from lifx_emulator_app.api.routers.monitoring import create_monitoring_router
27
+ from lifx_emulator_app.api.routers.products import create_products_router
25
28
  from lifx_emulator_app.api.routers.scenarios import create_scenarios_router
29
+ from lifx_emulator_app.api.routers.websocket import create_websocket_router
30
+ from lifx_emulator_app.api.services.event_bridge import (
31
+ StatsBroadcaster,
32
+ WebSocketActivityObserver,
33
+ wire_device_events,
34
+ )
35
+ from lifx_emulator_app.api.services.websocket_manager import WebSocketManager
26
36
 
27
37
  logger = logging.getLogger(__name__)
28
38
 
29
- # Asset directories for web UI
30
- TEMPLATES_DIR = Path(__file__).parent / "templates"
39
+ # Asset directory for web UI (Svelte build output)
31
40
  STATIC_DIR = Path(__file__).parent / "static"
32
- templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
33
41
 
34
42
 
35
43
  def create_api_app(server: EmulatedLifxServer) -> FastAPI:
@@ -52,7 +60,21 @@ def create_api_app(server: EmulatedLifxServer) -> FastAPI:
52
60
  >>> app = create_api_app(server)
53
61
  >>> # Run with: uvicorn app:app --host 127.0.0.1 --port 8080
54
62
  """
63
+ # Create WebSocket manager early so we can reference it in lifespan
64
+ ws_manager = WebSocketManager(server)
65
+ stats_broadcaster = StatsBroadcaster(server, ws_manager)
66
+
67
+ @asynccontextmanager
68
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
69
+ """Manage application lifecycle - start/stop background tasks."""
70
+ # Startup: start the stats broadcaster
71
+ stats_broadcaster.start()
72
+ yield
73
+ # Shutdown: stop the stats broadcaster
74
+ await stats_broadcaster.stop()
75
+
55
76
  app = FastAPI(
77
+ lifespan=lifespan,
56
78
  title="LIFX Emulator API",
57
79
  description="""
58
80
  Runtime management and monitoring API for LIFX device emulator.
@@ -69,10 +91,11 @@ LIFX LAN protocol.
69
91
  - OpenAPI 3.1.0 compliant schema
70
92
 
71
93
  ## Architecture
72
- The API is organized into three main routers:
94
+ The API is organized into four main routers:
73
95
  - **Monitoring**: Server stats and activity logs
74
96
  - **Devices**: Device CRUD operations
75
97
  - **Scenarios**: Test scenario configuration
98
+ - **Products**: LIFX product registry
76
99
  """,
77
100
  version="1.0.0",
78
101
  contact={
@@ -96,29 +119,112 @@ The API is organized into three main routers:
96
119
  "name": "scenarios",
97
120
  "description": "Test scenario management",
98
121
  },
122
+ {
123
+ "name": "products",
124
+ "description": "LIFX product registry",
125
+ },
126
+ {
127
+ "name": "websocket",
128
+ "description": """Real-time updates via WebSocket.
129
+
130
+ ## Connection
131
+
132
+ Connect to `ws://<host>:<port>/ws` to receive real-time updates.
133
+
134
+ ## Client Messages
135
+
136
+ ### Subscribe to Topics
137
+
138
+ ```json
139
+ {"type": "subscribe", "topics": ["stats", "devices", "activity", "scenarios"]}
140
+ ```
141
+
142
+ **Available topics:**
143
+ - `stats` - Server statistics (pushed every second)
144
+ - `devices` - Device add/remove/update events
145
+ - `activity` - Packet activity events (requires `--activity` flag)
146
+ - `scenarios` - Scenario configuration changes
147
+
148
+ ### Request Full State Sync
149
+
150
+ ```json
151
+ {"type": "sync"}
152
+ ```
153
+
154
+ Returns current state for all subscribed topics.
155
+
156
+ ## Server Messages
157
+
158
+ All server messages follow this format:
159
+
160
+ ```json
161
+ {"type": "<message_type>", "data": {...}}
162
+ ```
163
+
164
+ ### Message Types
165
+
166
+ | Type | Description |
167
+ |------|-------------|
168
+ | `sync` | Full state response containing `stats`, `devices`, `activity`, `scenarios` |
169
+ | `stats` | Server statistics update |
170
+ | `device_added` | New device created |
171
+ | `device_removed` | Device deleted (data: `{"serial": "..."}`) |
172
+ | `device_updated` | Device state changed (data: `{serial, changes}`) |
173
+ | `activity` | Packet activity event |
174
+ | `scenario_changed` | Scenario configuration changed |
175
+ | `error` | Error message (data: `{"message": "..."}`) |
176
+
177
+ ## Example Session
178
+
179
+ ```
180
+ -> {"type": "subscribe", "topics": ["devices", "stats"]}
181
+ -> {"type": "sync"}
182
+ <- {"type": "sync", "data": {"stats": {...}, "devices": [...]}}
183
+ <- {"type": "stats", "data": {"uptime_seconds": 123, ...}}
184
+ <- {"type": "device_added", "data": {"serial": "d073d5000001", ...}}
185
+ ```
186
+ """,
187
+ },
99
188
  ],
100
189
  )
101
190
 
102
- @app.get("/", response_class=HTMLResponse, include_in_schema=False)
103
- async def root(request: Request):
104
- """Serve embedded web UI dashboard."""
105
- return templates.TemplateResponse(request, "dashboard.html")
191
+ @app.get("/", include_in_schema=False)
192
+ async def root():
193
+ """Serve embedded Svelte dashboard."""
194
+ return FileResponse(STATIC_DIR / "index.html", media_type="text/html")
195
+
196
+ # Mount Svelte app assets at /_app (SvelteKit default)
197
+ app.mount("/_app", StaticFiles(directory=str(STATIC_DIR / "_app")), name="app")
106
198
 
107
- # Mount static files for JS/CSS assets (cached by browsers)
199
+ # Mount static files for backward compatibility
108
200
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
109
201
 
202
+ # Store WebSocket manager in app state for access by event handlers
203
+ app.state.ws_manager = ws_manager
204
+
205
+ # Wire device lifecycle events to WebSocket broadcasts
206
+ wire_device_events(server._device_manager, ws_manager)
207
+
208
+ # Wrap the activity observer with WebSocket broadcasting
209
+ # This preserves activity logging while adding real-time WebSocket updates
210
+ server.activity_observer = WebSocketActivityObserver(
211
+ ws_manager, server.activity_observer
212
+ )
213
+
110
214
  # Include routers with server dependency injection
111
215
  monitoring_router = create_monitoring_router(server)
112
216
  devices_router = create_devices_router(server)
113
- scenarios_router = create_scenarios_router(server)
217
+ scenarios_router = create_scenarios_router(server, ws_manager)
218
+ products_router = create_products_router()
219
+ websocket_router = create_websocket_router(ws_manager)
114
220
 
115
221
  app.include_router(monitoring_router)
116
222
  app.include_router(devices_router)
117
223
  app.include_router(scenarios_router)
224
+ app.include_router(products_router)
225
+ app.include_router(websocket_router)
118
226
 
119
- logger.info(
120
- "API application created with 3 routers (monitoring, devices, scenarios)"
121
- )
227
+ logger.info("API application created with 5 routers")
122
228
 
123
229
  return app
124
230
 
@@ -21,7 +21,7 @@ class DeviceCreateRequest(BaseModel):
21
21
  None, description="Number of zones for multizone devices", ge=0, le=1000
22
22
  )
23
23
  tile_count: int | None = Field(
24
- None, description="Number of tiles for matrix devices", ge=0, le=100
24
+ None, description="Number of tiles for matrix devices", ge=0, le=5
25
25
  )
26
26
  tile_width: int | None = Field(
27
27
  None, description="Width of each tile in zones", ge=1, le=256
@@ -118,6 +118,37 @@ class ActivityEvent(BaseModel):
118
118
  addr: str
119
119
 
120
120
 
121
+ class TileColorUpdate(BaseModel):
122
+ """Color update for a specific tile in a matrix device."""
123
+
124
+ tile_index: int = Field(..., ge=0, le=4)
125
+ colors: list[ColorHsbk] = Field(..., min_length=1, max_length=1024)
126
+
127
+
128
+ class DeviceStateUpdate(BaseModel):
129
+ """PATCH request body for updating device state — all fields optional."""
130
+
131
+ power_level: int | None = Field(None, ge=0, le=65535)
132
+ color: ColorHsbk | None = None
133
+ zone_colors: list[ColorHsbk] | None = Field(default=None, min_length=1)
134
+ tile_colors: list[TileColorUpdate] | None = Field(default=None, min_length=1)
135
+
136
+
137
+ class BulkDeviceCreateRequest(BaseModel):
138
+ """Request to create multiple devices at once."""
139
+
140
+ devices: list[DeviceCreateRequest] = Field(..., min_length=1, max_length=100)
141
+
142
+
143
+ class PaginatedDeviceList(BaseModel):
144
+ """Paginated list of devices."""
145
+
146
+ devices: list[DeviceInfo]
147
+ total: int
148
+ offset: int
149
+ limit: int
150
+
151
+
121
152
  class ScenarioResponse(BaseModel):
122
153
  """Response model for scenario operations."""
123
154
 
@@ -2,10 +2,14 @@
2
2
 
3
3
  from lifx_emulator_app.api.routers.devices import create_devices_router
4
4
  from lifx_emulator_app.api.routers.monitoring import create_monitoring_router
5
+ from lifx_emulator_app.api.routers.products import create_products_router
5
6
  from lifx_emulator_app.api.routers.scenarios import create_scenarios_router
7
+ from lifx_emulator_app.api.routers.websocket import create_websocket_router
6
8
 
7
9
  __all__ = [
8
- "create_monitoring_router",
9
10
  "create_devices_router",
11
+ "create_monitoring_router",
12
+ "create_products_router",
10
13
  "create_scenarios_router",
14
+ "create_websocket_router",
11
15
  ]
@@ -4,17 +4,24 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from fastapi import APIRouter, HTTPException
7
+ from fastapi import APIRouter, HTTPException, Query
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from lifx_emulator.server import EmulatedLifxServer
11
11
 
12
- from lifx_emulator_app.api.models import DeviceCreateRequest, DeviceInfo
12
+ from lifx_emulator_app.api.models import (
13
+ BulkDeviceCreateRequest,
14
+ DeviceCreateRequest,
15
+ DeviceInfo,
16
+ DeviceStateUpdate,
17
+ PaginatedDeviceList,
18
+ )
13
19
  from lifx_emulator_app.api.services.device_service import (
14
20
  DeviceAlreadyExistsError,
15
21
  DeviceCreationError,
16
22
  DeviceNotFoundError,
17
23
  DeviceService,
24
+ DeviceStateUpdateError,
18
25
  )
19
26
 
20
27
 
@@ -35,15 +42,24 @@ def create_devices_router(server: EmulatedLifxServer) -> APIRouter:
35
42
 
36
43
  @router.get(
37
44
  "",
38
- response_model=list[DeviceInfo],
45
+ response_model=PaginatedDeviceList,
39
46
  summary="List all devices",
40
47
  description=(
41
- "Returns a list of all emulated devices with their current configuration."
48
+ "Returns a paginated list of all emulated devices "
49
+ "with their current configuration."
42
50
  ),
43
51
  )
44
- async def list_devices():
45
- """List all emulated devices."""
46
- return device_service.list_all_devices()
52
+ async def list_devices(
53
+ offset: int = Query(0, ge=0, description="Number of devices to skip"),
54
+ limit: int = Query(
55
+ 50, ge=1, le=1000, description="Maximum number of devices to return"
56
+ ),
57
+ ):
58
+ """List all emulated devices with pagination."""
59
+ devices, total = device_service.list_devices_paginated(offset, limit)
60
+ return PaginatedDeviceList(
61
+ devices=devices, total=total, offset=offset, limit=limit
62
+ )
47
63
 
48
64
  @router.get(
49
65
  "/{serial}",
@@ -63,6 +79,27 @@ def create_devices_router(server: EmulatedLifxServer) -> APIRouter:
63
79
  except DeviceNotFoundError as e:
64
80
  raise HTTPException(status_code=404, detail=str(e))
65
81
 
82
+ @router.post(
83
+ "/bulk",
84
+ response_model=list[DeviceInfo],
85
+ status_code=201,
86
+ summary="Create multiple devices",
87
+ description="Creates multiple emulated devices at once.",
88
+ responses={
89
+ 201: {"description": "All devices created successfully"},
90
+ 400: {"description": "Invalid product ID or parameters"},
91
+ 409: {"description": "Duplicate serial in batch or existing device"},
92
+ },
93
+ )
94
+ async def create_devices_bulk(request: BulkDeviceCreateRequest):
95
+ """Create multiple devices at once."""
96
+ try:
97
+ return device_service.create_devices_bulk(request.devices)
98
+ except DeviceCreationError as e:
99
+ raise HTTPException(status_code=400, detail=str(e))
100
+ except DeviceAlreadyExistsError as e:
101
+ raise HTTPException(status_code=409, detail=str(e))
102
+
66
103
  @router.post(
67
104
  "",
68
105
  response_model=DeviceInfo,
@@ -87,6 +124,26 @@ def create_devices_router(server: EmulatedLifxServer) -> APIRouter:
87
124
  except DeviceAlreadyExistsError as e:
88
125
  raise HTTPException(status_code=409, detail=str(e))
89
126
 
127
+ @router.patch(
128
+ "/{serial}/state",
129
+ response_model=DeviceInfo,
130
+ summary="Update device state",
131
+ description="Updates the state of an existing device. All fields are optional.",
132
+ responses={
133
+ 200: {"description": "Device state updated successfully"},
134
+ 400: {"description": "Invalid state update for device capabilities"},
135
+ 404: {"description": "Device not found"},
136
+ },
137
+ )
138
+ async def update_device_state(serial: str, update: DeviceStateUpdate):
139
+ """Update device state."""
140
+ try:
141
+ return device_service.update_device_state(serial, update)
142
+ except DeviceNotFoundError as e:
143
+ raise HTTPException(status_code=404, detail=str(e))
144
+ except DeviceStateUpdateError as e:
145
+ raise HTTPException(status_code=400, detail=str(e))
146
+
90
147
  @router.delete(
91
148
  "/{serial}",
92
149
  status_code=204,
@@ -124,7 +181,4 @@ def create_devices_router(server: EmulatedLifxServer) -> APIRouter:
124
181
  count = device_service.clear_all_devices(delete_storage=False)
125
182
  return {"deleted": count, "message": f"Removed {count} device(s) from server"}
126
183
 
127
- # TODO: Add storage clear endpoint (was /api/storage in old API, not under /devices)
128
- # This should be handled separately or at the app level
129
-
130
184
  return router
@@ -0,0 +1,42 @@
1
+ """Product registry endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter
6
+ from lifx_emulator.products.registry import PRODUCTS
7
+
8
+
9
+ def create_products_router() -> APIRouter:
10
+ """Create products router.
11
+
12
+ Returns:
13
+ Configured APIRouter for product endpoints
14
+ """
15
+ router = APIRouter(prefix="/api/products", tags=["products"])
16
+
17
+ @router.get(
18
+ "",
19
+ status_code=200,
20
+ summary="List all known products",
21
+ description="Returns a list of all LIFX products from the product registry.",
22
+ )
23
+ async def list_products():
24
+ """List all products from the registry."""
25
+ return [
26
+ {
27
+ "pid": info.pid,
28
+ "name": info.name,
29
+ "vendor": info.vendor,
30
+ "has_color": info.has_color,
31
+ "has_infrared": info.has_infrared,
32
+ "has_multizone": info.has_multizone,
33
+ "has_chain": info.has_chain,
34
+ "has_matrix": info.has_matrix,
35
+ "has_relays": info.has_relays,
36
+ "has_buttons": info.has_buttons,
37
+ "has_hev": info.has_hev,
38
+ }
39
+ for info in sorted(PRODUCTS.values(), key=lambda p: p.pid)
40
+ ]
41
+
42
+ return router