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.
- {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/METADATA +2 -1
- lifx_emulator-4.2.0.dist-info/RECORD +43 -0
- lifx_emulator_app/__main__.py +35 -7
- lifx_emulator_app/api/__init__.py +0 -4
- lifx_emulator_app/api/app.py +122 -16
- lifx_emulator_app/api/models.py +32 -1
- lifx_emulator_app/api/routers/__init__.py +5 -1
- lifx_emulator_app/api/routers/devices.py +64 -10
- lifx_emulator_app/api/routers/products.py +42 -0
- lifx_emulator_app/api/routers/scenarios.py +55 -52
- lifx_emulator_app/api/routers/websocket.py +70 -0
- lifx_emulator_app/api/services/__init__.py +21 -4
- lifx_emulator_app/api/services/device_service.py +188 -1
- lifx_emulator_app/api/services/event_bridge.py +234 -0
- lifx_emulator_app/api/services/scenario_service.py +153 -0
- lifx_emulator_app/api/services/websocket_manager.py +326 -0
- lifx_emulator_app/api/static/_app/env.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css +1 -0
- lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js +2 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js +2 -0
- lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js +6 -0
- lifx_emulator_app/api/static/_app/version.json +1 -0
- lifx_emulator_app/api/static/index.html +38 -0
- lifx_emulator_app/api/static/robots.txt +3 -0
- lifx_emulator_app/config.py +2 -0
- lifx_emulator-4.0.0.dist-info/RECORD +0 -20
- lifx_emulator_app/api/static/dashboard.js +0 -588
- lifx_emulator_app/api/templates/dashboard.html +0 -357
- {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
- {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.
|
|
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,,
|
lifx_emulator_app/__main__.py
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
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"]
|
lifx_emulator_app/api/app.py
CHANGED
|
@@ -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
|
|
16
|
-
from fastapi.responses import
|
|
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
|
|
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
|
|
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("/",
|
|
103
|
-
async def root(
|
|
104
|
-
"""Serve embedded
|
|
105
|
-
return
|
|
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
|
|
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
|
|
lifx_emulator_app/api/models.py
CHANGED
|
@@ -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=
|
|
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
|
|
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=
|
|
45
|
+
response_model=PaginatedDeviceList,
|
|
39
46
|
summary="List all devices",
|
|
40
47
|
description=(
|
|
41
|
-
"Returns a list of all emulated devices
|
|
48
|
+
"Returns a paginated list of all emulated devices "
|
|
49
|
+
"with their current configuration."
|
|
42
50
|
),
|
|
43
51
|
)
|
|
44
|
-
async def list_devices(
|
|
45
|
-
"
|
|
46
|
-
|
|
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
|