lifx-emulator 3.1.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-3.1.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 +693 -137
  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 +316 -0
  38. lifx_emulator-3.1.0.dist-info/RECORD +0 -19
  39. lifx_emulator_app/api/static/dashboard.js +0 -588
  40. lifx_emulator_app/api/templates/dashboard.html +0 -357
  41. {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
  42. {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/entry_points.txt +0 -0
@@ -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