lifx-emulator 1.0.2__py3-none-any.whl → 2.1.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/__init__.py +1 -1
- lifx_emulator/__main__.py +26 -51
- lifx_emulator/api/__init__.py +18 -0
- lifx_emulator/api/app.py +154 -0
- lifx_emulator/api/mappers/__init__.py +5 -0
- lifx_emulator/api/mappers/device_mapper.py +114 -0
- lifx_emulator/api/models.py +133 -0
- lifx_emulator/api/routers/__init__.py +11 -0
- lifx_emulator/api/routers/devices.py +130 -0
- lifx_emulator/api/routers/monitoring.py +52 -0
- lifx_emulator/api/routers/scenarios.py +247 -0
- lifx_emulator/api/services/__init__.py +8 -0
- lifx_emulator/api/services/device_service.py +198 -0
- lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
- lifx_emulator/devices/__init__.py +37 -0
- lifx_emulator/devices/device.py +333 -0
- lifx_emulator/devices/manager.py +256 -0
- lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
- lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
- lifx_emulator/devices/states.py +346 -0
- lifx_emulator/factories/__init__.py +37 -0
- lifx_emulator/factories/builder.py +371 -0
- lifx_emulator/factories/default_config.py +158 -0
- lifx_emulator/factories/factory.py +221 -0
- lifx_emulator/factories/firmware_config.py +59 -0
- lifx_emulator/factories/serial_generator.py +82 -0
- lifx_emulator/handlers/base.py +1 -1
- lifx_emulator/handlers/device_handlers.py +10 -28
- lifx_emulator/handlers/light_handlers.py +5 -9
- lifx_emulator/handlers/multizone_handlers.py +1 -1
- lifx_emulator/handlers/tile_handlers.py +31 -11
- lifx_emulator/products/generator.py +389 -170
- lifx_emulator/products/registry.py +52 -40
- lifx_emulator/products/specs.py +12 -13
- lifx_emulator/protocol/base.py +175 -63
- lifx_emulator/protocol/generator.py +18 -5
- lifx_emulator/protocol/packets.py +7 -7
- lifx_emulator/protocol/protocol_types.py +35 -62
- lifx_emulator/repositories/__init__.py +22 -0
- lifx_emulator/repositories/device_repository.py +155 -0
- lifx_emulator/repositories/storage_backend.py +107 -0
- lifx_emulator/scenarios/__init__.py +22 -0
- lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
- lifx_emulator/scenarios/models.py +112 -0
- lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
- lifx_emulator/server.py +42 -66
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/METADATA +1 -1
- lifx_emulator-2.1.0.dist-info/RECORD +62 -0
- lifx_emulator/device.py +0 -750
- lifx_emulator/device_states.py +0 -114
- lifx_emulator/factories.py +0 -380
- lifx_emulator/storage_protocol.py +0 -100
- lifx_emulator-1.0.2.dist-info/RECORD +0 -40
- /lifx_emulator/{observers.py → devices/observers.py} +0 -0
- /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/entry_points.txt +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,944 +1,3 @@
|
|
|
1
|
-
"""FastAPI-based management API for LIFX emulator."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
7
|
-
|
|
8
|
-
from fastapi import FastAPI, HTTPException
|
|
9
|
-
from fastapi.responses import HTMLResponse
|
|
10
|
-
from pydantic import BaseModel, Field, field_validator
|
|
11
|
-
|
|
12
|
-
if TYPE_CHECKING:
|
|
13
|
-
from lifx_emulator.server import EmulatedLifxServer
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class DeviceCreateRequest(BaseModel):
|
|
19
|
-
"""Request to create a new device."""
|
|
20
|
-
|
|
21
|
-
product_id: int = Field(..., description="Product ID from LIFX registry")
|
|
22
|
-
serial: str | None = Field(
|
|
23
|
-
None, description="Optional serial (auto-generated if not provided)"
|
|
24
|
-
)
|
|
25
|
-
zone_count: int | None = Field(
|
|
26
|
-
None, description="Number of zones for multizone devices"
|
|
27
|
-
)
|
|
28
|
-
tile_count: int | None = Field(
|
|
29
|
-
None, description="Number of tiles for matrix devices"
|
|
30
|
-
)
|
|
31
|
-
tile_width: int | None = Field(None, description="Width of each tile in pixels")
|
|
32
|
-
tile_height: int | None = Field(None, description="Height of each tile in pixels")
|
|
33
|
-
firmware_major: int | None = Field(None, description="Firmware major version")
|
|
34
|
-
firmware_minor: int | None = Field(None, description="Firmware minor version")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class ColorHsbk(BaseModel):
|
|
38
|
-
"""HSBK color representation."""
|
|
39
|
-
|
|
40
|
-
hue: int
|
|
41
|
-
saturation: int
|
|
42
|
-
brightness: int
|
|
43
|
-
kelvin: int
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class DeviceInfo(BaseModel):
|
|
47
|
-
"""Device information response."""
|
|
48
|
-
|
|
49
|
-
serial: str
|
|
50
|
-
label: str
|
|
51
|
-
product: int
|
|
52
|
-
vendor: int
|
|
53
|
-
power_level: int
|
|
54
|
-
has_color: bool
|
|
55
|
-
has_infrared: bool
|
|
56
|
-
has_multizone: bool
|
|
57
|
-
has_extended_multizone: bool
|
|
58
|
-
has_matrix: bool
|
|
59
|
-
has_hev: bool
|
|
60
|
-
zone_count: int
|
|
61
|
-
tile_count: int
|
|
62
|
-
color: ColorHsbk | None = None
|
|
63
|
-
zone_colors: list[ColorHsbk] = Field(default_factory=list)
|
|
64
|
-
tile_devices: list[dict] = Field(default_factory=list)
|
|
65
|
-
# Metadata fields
|
|
66
|
-
version_major: int = 0
|
|
67
|
-
version_minor: int = 0
|
|
68
|
-
build_timestamp: int = 0
|
|
69
|
-
group_label: str = ""
|
|
70
|
-
location_label: str = ""
|
|
71
|
-
uptime_ns: int = 0
|
|
72
|
-
wifi_signal: float = 0.0
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class ServerStats(BaseModel):
|
|
76
|
-
"""Server statistics response."""
|
|
77
|
-
|
|
78
|
-
uptime_seconds: float
|
|
79
|
-
start_time: float
|
|
80
|
-
device_count: int
|
|
81
|
-
packets_received: int
|
|
82
|
-
packets_sent: int
|
|
83
|
-
packets_received_by_type: dict[int, int]
|
|
84
|
-
packets_sent_by_type: dict[int, int]
|
|
85
|
-
error_count: int
|
|
86
|
-
activity_enabled: bool
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
class ActivityEvent(BaseModel):
|
|
90
|
-
"""Recent activity event."""
|
|
91
|
-
|
|
92
|
-
timestamp: float
|
|
93
|
-
direction: str
|
|
94
|
-
packet_type: int
|
|
95
|
-
packet_name: str
|
|
96
|
-
device: str | None = None
|
|
97
|
-
target: str | None = None
|
|
98
|
-
addr: str
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# Scenario Management Models
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class ScenarioConfigModel(BaseModel):
|
|
105
|
-
"""Scenario configuration model for API."""
|
|
106
|
-
|
|
107
|
-
drop_packets: dict[int, float] = Field(
|
|
108
|
-
default_factory=dict,
|
|
109
|
-
description="Map of packet types to drop rates (0.1-1.0). "
|
|
110
|
-
"1.0 = always drop, 0.5 = drop 50%, 0.1 = drop 10%. "
|
|
111
|
-
"Example: {101: 1.0, 102: 0.6}",
|
|
112
|
-
)
|
|
113
|
-
response_delays: dict[int, float] = Field(
|
|
114
|
-
default_factory=dict,
|
|
115
|
-
description="Map of packet types to delay in seconds before responding",
|
|
116
|
-
)
|
|
117
|
-
malformed_packets: list[int] = Field(
|
|
118
|
-
default_factory=list,
|
|
119
|
-
description="List of packet types to send with truncated/corrupted payloads",
|
|
120
|
-
)
|
|
121
|
-
invalid_field_values: list[int] = Field(
|
|
122
|
-
default_factory=list,
|
|
123
|
-
description="List of packet types to send with all 0xFF bytes in fields",
|
|
124
|
-
)
|
|
125
|
-
firmware_version: tuple[int, int] | None = Field(
|
|
126
|
-
None, description="Override firmware version (major, minor). Example: [3, 70]"
|
|
127
|
-
)
|
|
128
|
-
partial_responses: list[int] = Field(
|
|
129
|
-
default_factory=list,
|
|
130
|
-
description="List of packet types to send with incomplete data",
|
|
131
|
-
)
|
|
132
|
-
send_unhandled: bool = Field(
|
|
133
|
-
False, description="Send unhandled message responses for unknown packet types"
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
@field_validator("drop_packets", mode="before")
|
|
137
|
-
@classmethod
|
|
138
|
-
def convert_drop_packets_keys(cls, v):
|
|
139
|
-
"""Convert string keys to integers for drop_packets."""
|
|
140
|
-
if isinstance(v, dict):
|
|
141
|
-
return {int(k): float(val) for k, val in v.items()}
|
|
142
|
-
return v
|
|
143
|
-
|
|
144
|
-
@field_validator("response_delays", mode="before")
|
|
145
|
-
@classmethod
|
|
146
|
-
def convert_response_delays_keys(cls, v):
|
|
147
|
-
"""Convert string keys to integers for response_delays."""
|
|
148
|
-
if isinstance(v, dict):
|
|
149
|
-
return {int(k): float(val) for k, val in v.items()}
|
|
150
|
-
return v
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
class ScenarioResponse(BaseModel):
|
|
154
|
-
"""Response model for scenario operations."""
|
|
155
|
-
|
|
156
|
-
scope: str = Field(
|
|
157
|
-
..., description="Scope of the scenario (global, device, type, location, group)"
|
|
158
|
-
)
|
|
159
|
-
identifier: str | None = Field(
|
|
160
|
-
None, description="Identifier for the scope (serial, type name, etc.)"
|
|
161
|
-
)
|
|
162
|
-
scenario: ScenarioConfigModel = Field(..., description="The scenario configuration")
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def create_api_app(server: EmulatedLifxServer) -> FastAPI:
|
|
166
|
-
"""Create FastAPI application for emulator management.
|
|
167
|
-
|
|
168
|
-
Args:
|
|
169
|
-
server: The LIFX emulator server instance
|
|
170
|
-
|
|
171
|
-
Returns:
|
|
172
|
-
FastAPI application
|
|
173
|
-
"""
|
|
174
|
-
app = FastAPI(
|
|
175
|
-
title="LIFX Emulator API",
|
|
176
|
-
description="""
|
|
177
|
-
Runtime management and monitoring API for LIFX device emulator.
|
|
178
|
-
|
|
179
|
-
This API provides read-only monitoring of the emulator state and device management
|
|
180
|
-
capabilities (add/remove devices). Device state changes must be performed via the
|
|
181
|
-
LIFX LAN protocol.
|
|
182
|
-
|
|
183
|
-
## Features
|
|
184
|
-
- Real-time server statistics and packet monitoring
|
|
185
|
-
- Device inspection and management
|
|
186
|
-
- Recent activity tracking
|
|
187
|
-
- OpenAPI 3.1.0 compliant schema
|
|
188
|
-
""",
|
|
189
|
-
version="1.0.0",
|
|
190
|
-
contact={
|
|
191
|
-
"name": "LIFX Emulator",
|
|
192
|
-
"url": "https://github.com/Djelibeybi/lifx-emulator",
|
|
193
|
-
},
|
|
194
|
-
license_info={
|
|
195
|
-
"name": "UPL-1.0",
|
|
196
|
-
"url": "https://opensource.org/licenses/UPL",
|
|
197
|
-
},
|
|
198
|
-
openapi_tags=[
|
|
199
|
-
{
|
|
200
|
-
"name": "monitoring",
|
|
201
|
-
"description": "Server statistics and activity monitoring",
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
"name": "devices",
|
|
205
|
-
"description": "Device management and inspection",
|
|
206
|
-
},
|
|
207
|
-
{
|
|
208
|
-
"name": "scenarios",
|
|
209
|
-
"description": (
|
|
210
|
-
"Test scenario management for simulating device behaviors"
|
|
211
|
-
),
|
|
212
|
-
},
|
|
213
|
-
],
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
217
|
-
async def root():
|
|
218
|
-
"""Serve web UI."""
|
|
219
|
-
return HTML_UI
|
|
220
|
-
|
|
221
|
-
@app.get(
|
|
222
|
-
"/api/stats",
|
|
223
|
-
response_model=ServerStats,
|
|
224
|
-
tags=["monitoring"],
|
|
225
|
-
summary="Get server statistics",
|
|
226
|
-
description=(
|
|
227
|
-
"Returns server uptime, packet counts, error counts, and device count."
|
|
228
|
-
),
|
|
229
|
-
)
|
|
230
|
-
async def get_stats():
|
|
231
|
-
"""Get server statistics."""
|
|
232
|
-
return server.get_stats()
|
|
233
|
-
|
|
234
|
-
@app.get(
|
|
235
|
-
"/api/devices",
|
|
236
|
-
response_model=list[DeviceInfo],
|
|
237
|
-
tags=["devices"],
|
|
238
|
-
summary="List all devices",
|
|
239
|
-
description=(
|
|
240
|
-
"Returns a list of all emulated devices with their current configuration."
|
|
241
|
-
),
|
|
242
|
-
)
|
|
243
|
-
async def list_devices():
|
|
244
|
-
"""List all emulated devices."""
|
|
245
|
-
devices = server.get_all_devices()
|
|
246
|
-
result = []
|
|
247
|
-
for dev in devices:
|
|
248
|
-
device_info = DeviceInfo(
|
|
249
|
-
serial=dev.state.serial,
|
|
250
|
-
label=dev.state.label,
|
|
251
|
-
product=dev.state.product,
|
|
252
|
-
vendor=dev.state.vendor,
|
|
253
|
-
power_level=dev.state.power_level,
|
|
254
|
-
has_color=dev.state.has_color,
|
|
255
|
-
has_infrared=dev.state.has_infrared,
|
|
256
|
-
has_multizone=dev.state.has_multizone,
|
|
257
|
-
has_extended_multizone=dev.state.has_extended_multizone,
|
|
258
|
-
has_matrix=dev.state.has_matrix,
|
|
259
|
-
has_hev=dev.state.has_hev,
|
|
260
|
-
zone_count=dev.state.multizone.zone_count
|
|
261
|
-
if dev.state.multizone is not None
|
|
262
|
-
else 0,
|
|
263
|
-
tile_count=dev.state.matrix.tile_count
|
|
264
|
-
if dev.state.matrix is not None
|
|
265
|
-
else 0,
|
|
266
|
-
color=ColorHsbk(
|
|
267
|
-
hue=dev.state.color.hue,
|
|
268
|
-
saturation=dev.state.color.saturation,
|
|
269
|
-
brightness=dev.state.color.brightness,
|
|
270
|
-
kelvin=dev.state.color.kelvin,
|
|
271
|
-
)
|
|
272
|
-
if dev.state.has_color
|
|
273
|
-
else None,
|
|
274
|
-
zone_colors=[
|
|
275
|
-
ColorHsbk(
|
|
276
|
-
hue=c.hue,
|
|
277
|
-
saturation=c.saturation,
|
|
278
|
-
brightness=c.brightness,
|
|
279
|
-
kelvin=c.kelvin,
|
|
280
|
-
)
|
|
281
|
-
for c in dev.state.multizone.zone_colors
|
|
282
|
-
]
|
|
283
|
-
if dev.state.multizone is not None
|
|
284
|
-
else [],
|
|
285
|
-
tile_devices=dev.state.matrix.tile_devices
|
|
286
|
-
if dev.state.matrix is not None
|
|
287
|
-
else [],
|
|
288
|
-
version_major=dev.state.version_major,
|
|
289
|
-
version_minor=dev.state.version_minor,
|
|
290
|
-
build_timestamp=dev.state.build_timestamp,
|
|
291
|
-
group_label=dev.state.group.group_label,
|
|
292
|
-
location_label=dev.state.location.location_label,
|
|
293
|
-
uptime_ns=dev.state.uptime_ns,
|
|
294
|
-
wifi_signal=dev.state.wifi_signal,
|
|
295
|
-
)
|
|
296
|
-
result.append(device_info)
|
|
297
|
-
return result
|
|
298
|
-
|
|
299
|
-
@app.get(
|
|
300
|
-
"/api/devices/{serial}",
|
|
301
|
-
response_model=DeviceInfo,
|
|
302
|
-
tags=["devices"],
|
|
303
|
-
summary="Get device information",
|
|
304
|
-
description=(
|
|
305
|
-
"Returns detailed information about a specific device by its serial number."
|
|
306
|
-
),
|
|
307
|
-
responses={
|
|
308
|
-
404: {"description": "Device not found"},
|
|
309
|
-
},
|
|
310
|
-
)
|
|
311
|
-
async def get_device(serial: str):
|
|
312
|
-
"""Get specific device information."""
|
|
313
|
-
device = server.get_device(serial)
|
|
314
|
-
if not device:
|
|
315
|
-
raise HTTPException(status_code=404, detail=f"Device {serial} not found")
|
|
316
|
-
|
|
317
|
-
return DeviceInfo(
|
|
318
|
-
serial=device.state.serial,
|
|
319
|
-
label=device.state.label,
|
|
320
|
-
product=device.state.product,
|
|
321
|
-
vendor=device.state.vendor,
|
|
322
|
-
power_level=device.state.power_level,
|
|
323
|
-
has_color=device.state.has_color,
|
|
324
|
-
has_infrared=device.state.has_infrared,
|
|
325
|
-
has_multizone=device.state.has_multizone,
|
|
326
|
-
has_extended_multizone=device.state.has_extended_multizone,
|
|
327
|
-
has_matrix=device.state.has_matrix,
|
|
328
|
-
has_hev=device.state.has_hev,
|
|
329
|
-
zone_count=device.state.multizone.zone_count
|
|
330
|
-
if device.state.multizone is not None
|
|
331
|
-
else 0,
|
|
332
|
-
tile_count=device.state.matrix.tile_count
|
|
333
|
-
if device.state.matrix is not None
|
|
334
|
-
else 0,
|
|
335
|
-
color=ColorHsbk(
|
|
336
|
-
hue=device.state.color.hue,
|
|
337
|
-
saturation=device.state.color.saturation,
|
|
338
|
-
brightness=device.state.color.brightness,
|
|
339
|
-
kelvin=device.state.color.kelvin,
|
|
340
|
-
)
|
|
341
|
-
if device.state.has_color
|
|
342
|
-
else None,
|
|
343
|
-
zone_colors=[
|
|
344
|
-
ColorHsbk(
|
|
345
|
-
hue=c.hue,
|
|
346
|
-
saturation=c.saturation,
|
|
347
|
-
brightness=c.brightness,
|
|
348
|
-
kelvin=c.kelvin,
|
|
349
|
-
)
|
|
350
|
-
for c in device.state.multizone.zone_colors
|
|
351
|
-
]
|
|
352
|
-
if device.state.multizone is not None
|
|
353
|
-
else [],
|
|
354
|
-
tile_devices=device.state.matrix.tile_devices
|
|
355
|
-
if device.state.matrix is not None
|
|
356
|
-
else [],
|
|
357
|
-
version_major=device.state.version_major,
|
|
358
|
-
version_minor=device.state.version_minor,
|
|
359
|
-
build_timestamp=device.state.build_timestamp,
|
|
360
|
-
group_label=device.state.group.group_label,
|
|
361
|
-
location_label=device.state.location.location_label,
|
|
362
|
-
uptime_ns=device.state.uptime_ns,
|
|
363
|
-
wifi_signal=device.state.wifi_signal,
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
@app.post(
|
|
367
|
-
"/api/devices",
|
|
368
|
-
response_model=DeviceInfo,
|
|
369
|
-
status_code=201,
|
|
370
|
-
tags=["devices"],
|
|
371
|
-
summary="Create a new device",
|
|
372
|
-
description=(
|
|
373
|
-
"Creates a new emulated device by product ID. "
|
|
374
|
-
"The device will be added to the emulator immediately."
|
|
375
|
-
),
|
|
376
|
-
responses={
|
|
377
|
-
201: {"description": "Device created successfully"},
|
|
378
|
-
400: {"description": "Invalid product ID or parameters"},
|
|
379
|
-
409: {"description": "Device with this serial already exists"},
|
|
380
|
-
},
|
|
381
|
-
)
|
|
382
|
-
async def create_device(request: DeviceCreateRequest):
|
|
383
|
-
"""Create a new device."""
|
|
384
|
-
from lifx_emulator.factories import create_device
|
|
385
|
-
|
|
386
|
-
# Build firmware_version tuple if both major and minor are provided
|
|
387
|
-
firmware_version = None
|
|
388
|
-
if request.firmware_major is not None and request.firmware_minor is not None:
|
|
389
|
-
firmware_version = (request.firmware_major, request.firmware_minor)
|
|
390
|
-
|
|
391
|
-
try:
|
|
392
|
-
device = create_device(
|
|
393
|
-
product_id=request.product_id,
|
|
394
|
-
serial=request.serial,
|
|
395
|
-
zone_count=request.zone_count,
|
|
396
|
-
tile_count=request.tile_count,
|
|
397
|
-
tile_width=request.tile_width,
|
|
398
|
-
tile_height=request.tile_height,
|
|
399
|
-
firmware_version=firmware_version,
|
|
400
|
-
storage=server.storage,
|
|
401
|
-
)
|
|
402
|
-
except Exception as e:
|
|
403
|
-
raise HTTPException(status_code=400, detail=f"Failed to create device: {e}")
|
|
404
|
-
|
|
405
|
-
if not server.add_device(device):
|
|
406
|
-
raise HTTPException(
|
|
407
|
-
status_code=409,
|
|
408
|
-
detail=f"Device with serial {device.state.serial} already exists",
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
return DeviceInfo(
|
|
412
|
-
serial=device.state.serial,
|
|
413
|
-
label=device.state.label,
|
|
414
|
-
product=device.state.product,
|
|
415
|
-
vendor=device.state.vendor,
|
|
416
|
-
power_level=device.state.power_level,
|
|
417
|
-
has_color=device.state.has_color,
|
|
418
|
-
has_infrared=device.state.has_infrared,
|
|
419
|
-
has_multizone=device.state.has_multizone,
|
|
420
|
-
has_extended_multizone=device.state.has_extended_multizone,
|
|
421
|
-
has_matrix=device.state.has_matrix,
|
|
422
|
-
has_hev=device.state.has_hev,
|
|
423
|
-
zone_count=device.state.multizone.zone_count
|
|
424
|
-
if device.state.multizone is not None
|
|
425
|
-
else 0,
|
|
426
|
-
tile_count=device.state.matrix.tile_count
|
|
427
|
-
if device.state.matrix is not None
|
|
428
|
-
else 0,
|
|
429
|
-
color=ColorHsbk(
|
|
430
|
-
hue=device.state.color.hue,
|
|
431
|
-
saturation=device.state.color.saturation,
|
|
432
|
-
brightness=device.state.color.brightness,
|
|
433
|
-
kelvin=device.state.color.kelvin,
|
|
434
|
-
)
|
|
435
|
-
if device.state.has_color
|
|
436
|
-
else None,
|
|
437
|
-
zone_colors=[
|
|
438
|
-
ColorHsbk(
|
|
439
|
-
hue=c.hue,
|
|
440
|
-
saturation=c.saturation,
|
|
441
|
-
brightness=c.brightness,
|
|
442
|
-
kelvin=c.kelvin,
|
|
443
|
-
)
|
|
444
|
-
for c in device.state.multizone.zone_colors
|
|
445
|
-
]
|
|
446
|
-
if device.state.multizone is not None
|
|
447
|
-
else [],
|
|
448
|
-
tile_devices=device.state.matrix.tile_devices
|
|
449
|
-
if device.state.matrix is not None
|
|
450
|
-
else [],
|
|
451
|
-
version_major=device.state.version_major,
|
|
452
|
-
version_minor=device.state.version_minor,
|
|
453
|
-
build_timestamp=device.state.build_timestamp,
|
|
454
|
-
group_label=device.state.group.group_label,
|
|
455
|
-
location_label=device.state.location.location_label,
|
|
456
|
-
uptime_ns=device.state.uptime_ns,
|
|
457
|
-
wifi_signal=device.state.wifi_signal,
|
|
458
|
-
)
|
|
459
|
-
|
|
460
|
-
@app.delete(
|
|
461
|
-
"/api/devices/{serial}",
|
|
462
|
-
status_code=204,
|
|
463
|
-
tags=["devices"],
|
|
464
|
-
summary="Delete a device",
|
|
465
|
-
description=(
|
|
466
|
-
"Removes an emulated device from the server. "
|
|
467
|
-
"The device will stop responding to LIFX protocol packets."
|
|
468
|
-
),
|
|
469
|
-
responses={
|
|
470
|
-
204: {"description": "Device deleted successfully"},
|
|
471
|
-
404: {"description": "Device not found"},
|
|
472
|
-
},
|
|
473
|
-
)
|
|
474
|
-
async def delete_device(serial: str):
|
|
475
|
-
"""Delete a device."""
|
|
476
|
-
if not server.remove_device(serial):
|
|
477
|
-
raise HTTPException(status_code=404, detail=f"Device {serial} not found")
|
|
478
|
-
|
|
479
|
-
@app.delete(
|
|
480
|
-
"/api/devices",
|
|
481
|
-
status_code=200,
|
|
482
|
-
tags=["devices"],
|
|
483
|
-
summary="Delete all devices",
|
|
484
|
-
description=(
|
|
485
|
-
"Removes all emulated devices from the server. "
|
|
486
|
-
"All devices will stop responding to LIFX protocol packets."
|
|
487
|
-
),
|
|
488
|
-
responses={
|
|
489
|
-
200: {"description": "All devices deleted successfully"},
|
|
490
|
-
},
|
|
491
|
-
)
|
|
492
|
-
async def delete_all_devices():
|
|
493
|
-
"""Delete all devices from the running server."""
|
|
494
|
-
count = server.remove_all_devices(delete_storage=False)
|
|
495
|
-
return {"deleted": count, "message": f"Removed {count} device(s) from server"}
|
|
496
|
-
|
|
497
|
-
@app.delete(
|
|
498
|
-
"/api/storage",
|
|
499
|
-
status_code=200,
|
|
500
|
-
tags=["devices"],
|
|
501
|
-
summary="Clear persistent storage",
|
|
502
|
-
description=(
|
|
503
|
-
"Deletes all persistent device state files from disk. "
|
|
504
|
-
"This does not affect currently running devices, only saved state files."
|
|
505
|
-
),
|
|
506
|
-
responses={
|
|
507
|
-
200: {"description": "Storage cleared successfully"},
|
|
508
|
-
503: {"description": "Persistent storage not enabled"},
|
|
509
|
-
},
|
|
510
|
-
)
|
|
511
|
-
async def clear_storage():
|
|
512
|
-
"""Clear all persistent device state from storage."""
|
|
513
|
-
if not server.storage:
|
|
514
|
-
raise HTTPException(
|
|
515
|
-
status_code=503, detail="Persistent storage is not enabled"
|
|
516
|
-
)
|
|
517
|
-
|
|
518
|
-
deleted = server.storage.delete_all_device_states()
|
|
519
|
-
return {
|
|
520
|
-
"deleted": deleted,
|
|
521
|
-
"message": f"Deleted {deleted} device state(s) from persistent storage",
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
@app.get(
|
|
525
|
-
"/api/activity",
|
|
526
|
-
response_model=list[ActivityEvent],
|
|
527
|
-
tags=["monitoring"],
|
|
528
|
-
summary="Get recent activity",
|
|
529
|
-
description=(
|
|
530
|
-
"Returns the last 100 packet events (TX/RX) "
|
|
531
|
-
"with timestamps and packet details."
|
|
532
|
-
),
|
|
533
|
-
)
|
|
534
|
-
async def get_activity():
|
|
535
|
-
"""Get recent activity events."""
|
|
536
|
-
return [ActivityEvent(**event) for event in server.get_recent_activity()]
|
|
537
|
-
|
|
538
|
-
# Scenario Management Endpoints
|
|
539
|
-
|
|
540
|
-
def _scenario_config_to_model(config) -> ScenarioConfigModel:
|
|
541
|
-
"""Convert ScenarioConfig to Pydantic model."""
|
|
542
|
-
from lifx_emulator.scenario_manager import ScenarioConfig
|
|
543
|
-
|
|
544
|
-
if isinstance(config, ScenarioConfig):
|
|
545
|
-
return ScenarioConfigModel(
|
|
546
|
-
drop_packets=config.drop_packets,
|
|
547
|
-
response_delays=config.response_delays,
|
|
548
|
-
malformed_packets=config.malformed_packets,
|
|
549
|
-
invalid_field_values=config.invalid_field_values,
|
|
550
|
-
firmware_version=config.firmware_version,
|
|
551
|
-
partial_responses=config.partial_responses,
|
|
552
|
-
send_unhandled=config.send_unhandled,
|
|
553
|
-
)
|
|
554
|
-
return ScenarioConfigModel(**config)
|
|
555
|
-
|
|
556
|
-
def _model_to_scenario_config(model: ScenarioConfigModel):
|
|
557
|
-
"""Convert Pydantic model to ScenarioConfig."""
|
|
558
|
-
from lifx_emulator.scenario_manager import ScenarioConfig
|
|
559
|
-
|
|
560
|
-
return ScenarioConfig(
|
|
561
|
-
drop_packets=model.drop_packets,
|
|
562
|
-
response_delays=model.response_delays,
|
|
563
|
-
malformed_packets=model.malformed_packets,
|
|
564
|
-
invalid_field_values=model.invalid_field_values,
|
|
565
|
-
firmware_version=model.firmware_version,
|
|
566
|
-
partial_responses=model.partial_responses,
|
|
567
|
-
send_unhandled=model.send_unhandled,
|
|
568
|
-
)
|
|
569
|
-
|
|
570
|
-
@app.get(
|
|
571
|
-
"/api/scenarios/global",
|
|
572
|
-
response_model=ScenarioResponse,
|
|
573
|
-
tags=["scenarios"],
|
|
574
|
-
summary="Get global scenario",
|
|
575
|
-
description=(
|
|
576
|
-
"Returns the global scenario that applies to all devices as a baseline."
|
|
577
|
-
),
|
|
578
|
-
)
|
|
579
|
-
async def get_global_scenario():
|
|
580
|
-
"""Get global scenario configuration."""
|
|
581
|
-
config = server.scenario_manager.get_global_scenario()
|
|
582
|
-
return ScenarioResponse(
|
|
583
|
-
scope="global", identifier=None, scenario=_scenario_config_to_model(config)
|
|
584
|
-
)
|
|
585
|
-
|
|
586
|
-
@app.put(
|
|
587
|
-
"/api/scenarios/global",
|
|
588
|
-
response_model=ScenarioResponse,
|
|
589
|
-
tags=["scenarios"],
|
|
590
|
-
summary="Set global scenario",
|
|
591
|
-
description=(
|
|
592
|
-
"Sets the global scenario that applies to all devices as a baseline."
|
|
593
|
-
),
|
|
594
|
-
)
|
|
595
|
-
async def set_global_scenario(scenario: ScenarioConfigModel):
|
|
596
|
-
"""Set global scenario configuration."""
|
|
597
|
-
config = _model_to_scenario_config(scenario)
|
|
598
|
-
server.scenario_manager.set_global_scenario(config)
|
|
599
|
-
|
|
600
|
-
# Invalidate cache for all devices
|
|
601
|
-
for device in server.get_all_devices():
|
|
602
|
-
device.invalidate_scenario_cache()
|
|
603
|
-
|
|
604
|
-
# Save to disk if persistence is enabled
|
|
605
|
-
if server.scenario_persistence:
|
|
606
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
607
|
-
|
|
608
|
-
return ScenarioResponse(scope="global", identifier=None, scenario=scenario)
|
|
609
|
-
|
|
610
|
-
@app.delete(
|
|
611
|
-
"/api/scenarios/global",
|
|
612
|
-
status_code=204,
|
|
613
|
-
tags=["scenarios"],
|
|
614
|
-
summary="Clear global scenario",
|
|
615
|
-
description="Clears the global scenario, resetting it to defaults.",
|
|
616
|
-
)
|
|
617
|
-
async def clear_global_scenario():
|
|
618
|
-
"""Clear global scenario configuration."""
|
|
619
|
-
server.scenario_manager.clear_global_scenario()
|
|
620
|
-
|
|
621
|
-
# Invalidate cache for all devices
|
|
622
|
-
for device in server.get_all_devices():
|
|
623
|
-
device.invalidate_scenario_cache()
|
|
624
|
-
|
|
625
|
-
# Save to disk if persistence is enabled
|
|
626
|
-
if server.scenario_persistence:
|
|
627
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
628
|
-
|
|
629
|
-
@app.get(
|
|
630
|
-
"/api/scenarios/devices/{serial}",
|
|
631
|
-
response_model=ScenarioResponse,
|
|
632
|
-
tags=["scenarios"],
|
|
633
|
-
summary="Get device-specific scenario",
|
|
634
|
-
description=(
|
|
635
|
-
"Returns the scenario configuration for a specific device by serial number."
|
|
636
|
-
),
|
|
637
|
-
responses={404: {"description": "Device scenario not found"}},
|
|
638
|
-
)
|
|
639
|
-
async def get_device_scenario(serial: str):
|
|
640
|
-
"""Get device-specific scenario."""
|
|
641
|
-
config = server.scenario_manager.get_device_scenario(serial)
|
|
642
|
-
if config is None:
|
|
643
|
-
raise HTTPException(
|
|
644
|
-
status_code=404, detail=f"No scenario found for device {serial}"
|
|
645
|
-
)
|
|
646
|
-
return ScenarioResponse(
|
|
647
|
-
scope="device",
|
|
648
|
-
identifier=serial,
|
|
649
|
-
scenario=_scenario_config_to_model(config),
|
|
650
|
-
)
|
|
651
|
-
|
|
652
|
-
@app.put(
|
|
653
|
-
"/api/scenarios/devices/{serial}",
|
|
654
|
-
response_model=ScenarioResponse,
|
|
655
|
-
tags=["scenarios"],
|
|
656
|
-
summary="Set device-specific scenario",
|
|
657
|
-
description="Sets a scenario that applies only to the specified device.",
|
|
658
|
-
)
|
|
659
|
-
async def set_device_scenario(serial: str, scenario: ScenarioConfigModel):
|
|
660
|
-
"""Set device-specific scenario."""
|
|
661
|
-
# Verify device exists
|
|
662
|
-
device = server.get_device(serial)
|
|
663
|
-
if not device:
|
|
664
|
-
raise HTTPException(status_code=404, detail=f"Device {serial} not found")
|
|
665
|
-
|
|
666
|
-
config = _model_to_scenario_config(scenario)
|
|
667
|
-
server.scenario_manager.set_device_scenario(serial, config)
|
|
668
|
-
|
|
669
|
-
# Invalidate cache for this device
|
|
670
|
-
device.invalidate_scenario_cache()
|
|
671
|
-
|
|
672
|
-
# Save to disk if persistence is enabled
|
|
673
|
-
if server.scenario_persistence:
|
|
674
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
675
|
-
|
|
676
|
-
return ScenarioResponse(scope="device", identifier=serial, scenario=scenario)
|
|
677
|
-
|
|
678
|
-
@app.delete(
|
|
679
|
-
"/api/scenarios/devices/{serial}",
|
|
680
|
-
status_code=204,
|
|
681
|
-
tags=["scenarios"],
|
|
682
|
-
summary="Clear device-specific scenario",
|
|
683
|
-
description="Clears the scenario for the specified device.",
|
|
684
|
-
responses={404: {"description": "Device scenario not found"}},
|
|
685
|
-
)
|
|
686
|
-
async def clear_device_scenario(serial: str):
|
|
687
|
-
"""Clear device-specific scenario."""
|
|
688
|
-
if not server.scenario_manager.delete_device_scenario(serial):
|
|
689
|
-
raise HTTPException(
|
|
690
|
-
status_code=404, detail=f"No scenario found for device {serial}"
|
|
691
|
-
)
|
|
692
|
-
|
|
693
|
-
# Invalidate cache if device exists
|
|
694
|
-
device = server.get_device(serial)
|
|
695
|
-
if device:
|
|
696
|
-
device.invalidate_scenario_cache()
|
|
697
|
-
|
|
698
|
-
# Save to disk if persistence is enabled
|
|
699
|
-
if server.scenario_persistence:
|
|
700
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
701
|
-
|
|
702
|
-
@app.get(
|
|
703
|
-
"/api/scenarios/types/{device_type}",
|
|
704
|
-
response_model=ScenarioResponse,
|
|
705
|
-
tags=["scenarios"],
|
|
706
|
-
summary="Get type-specific scenario",
|
|
707
|
-
description=(
|
|
708
|
-
"Returns the scenario for a device type (matrix, multizone, color, etc.)."
|
|
709
|
-
),
|
|
710
|
-
responses={404: {"description": "Type scenario not found"}},
|
|
711
|
-
)
|
|
712
|
-
async def get_type_scenario(device_type: str):
|
|
713
|
-
"""Get type-specific scenario."""
|
|
714
|
-
config = server.scenario_manager.get_type_scenario(device_type)
|
|
715
|
-
if config is None:
|
|
716
|
-
raise HTTPException(
|
|
717
|
-
status_code=404, detail=f"No scenario found for type {device_type}"
|
|
718
|
-
)
|
|
719
|
-
return ScenarioResponse(
|
|
720
|
-
scope="type",
|
|
721
|
-
identifier=device_type,
|
|
722
|
-
scenario=_scenario_config_to_model(config),
|
|
723
|
-
)
|
|
724
|
-
|
|
725
|
-
@app.put(
|
|
726
|
-
"/api/scenarios/types/{device_type}",
|
|
727
|
-
response_model=ScenarioResponse,
|
|
728
|
-
tags=["scenarios"],
|
|
729
|
-
summary="Set type-specific scenario",
|
|
730
|
-
description=(
|
|
731
|
-
"Sets a scenario that applies to all devices "
|
|
732
|
-
"of a specific type. "
|
|
733
|
-
"Valid types: matrix, multizone, color, infrared, hev"
|
|
734
|
-
),
|
|
735
|
-
)
|
|
736
|
-
async def set_type_scenario(device_type: str, scenario: ScenarioConfigModel):
|
|
737
|
-
"""Set type-specific scenario."""
|
|
738
|
-
config = _model_to_scenario_config(scenario)
|
|
739
|
-
server.scenario_manager.set_type_scenario(device_type, config)
|
|
740
|
-
|
|
741
|
-
# Invalidate cache for all devices
|
|
742
|
-
for device in server.get_all_devices():
|
|
743
|
-
device.invalidate_scenario_cache()
|
|
744
|
-
|
|
745
|
-
# Save to disk if persistence is enabled
|
|
746
|
-
if server.scenario_persistence:
|
|
747
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
748
|
-
|
|
749
|
-
return ScenarioResponse(scope="type", identifier=device_type, scenario=scenario)
|
|
750
|
-
|
|
751
|
-
@app.delete(
|
|
752
|
-
"/api/scenarios/types/{device_type}",
|
|
753
|
-
status_code=204,
|
|
754
|
-
tags=["scenarios"],
|
|
755
|
-
summary="Clear type-specific scenario",
|
|
756
|
-
description="Clears the scenario for the specified device type.",
|
|
757
|
-
responses={404: {"description": "Type scenario not found"}},
|
|
758
|
-
)
|
|
759
|
-
async def clear_type_scenario(device_type: str):
|
|
760
|
-
"""Clear type-specific scenario."""
|
|
761
|
-
if not server.scenario_manager.delete_type_scenario(device_type):
|
|
762
|
-
raise HTTPException(
|
|
763
|
-
status_code=404, detail=f"No scenario found for type {device_type}"
|
|
764
|
-
)
|
|
765
|
-
|
|
766
|
-
# Invalidate cache for all devices
|
|
767
|
-
for device in server.get_all_devices():
|
|
768
|
-
device.invalidate_scenario_cache()
|
|
769
|
-
|
|
770
|
-
# Save to disk if persistence is enabled
|
|
771
|
-
if server.scenario_persistence:
|
|
772
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
773
|
-
|
|
774
|
-
@app.get(
|
|
775
|
-
"/api/scenarios/locations/{location}",
|
|
776
|
-
response_model=ScenarioResponse,
|
|
777
|
-
tags=["scenarios"],
|
|
778
|
-
summary="Get location-specific scenario",
|
|
779
|
-
description="Returns the scenario for a specific location.",
|
|
780
|
-
responses={404: {"description": "Location scenario not found"}},
|
|
781
|
-
)
|
|
782
|
-
async def get_location_scenario(location: str):
|
|
783
|
-
"""Get location-specific scenario."""
|
|
784
|
-
config = server.scenario_manager.get_location_scenario(location)
|
|
785
|
-
if config is None:
|
|
786
|
-
raise HTTPException(
|
|
787
|
-
status_code=404, detail=f"No scenario found for location {location}"
|
|
788
|
-
)
|
|
789
|
-
return ScenarioResponse(
|
|
790
|
-
scope="location",
|
|
791
|
-
identifier=location,
|
|
792
|
-
scenario=_scenario_config_to_model(config),
|
|
793
|
-
)
|
|
794
|
-
|
|
795
|
-
@app.put(
|
|
796
|
-
"/api/scenarios/locations/{location}",
|
|
797
|
-
response_model=ScenarioResponse,
|
|
798
|
-
tags=["scenarios"],
|
|
799
|
-
summary="Set location-specific scenario",
|
|
800
|
-
description=(
|
|
801
|
-
"Sets a scenario that applies to all devices in a specific location."
|
|
802
|
-
),
|
|
803
|
-
)
|
|
804
|
-
async def set_location_scenario(location: str, scenario: ScenarioConfigModel):
|
|
805
|
-
"""Set location-specific scenario."""
|
|
806
|
-
config = _model_to_scenario_config(scenario)
|
|
807
|
-
server.scenario_manager.set_location_scenario(location, config)
|
|
808
|
-
|
|
809
|
-
# Invalidate cache for all devices
|
|
810
|
-
for device in server.get_all_devices():
|
|
811
|
-
device.invalidate_scenario_cache()
|
|
812
|
-
|
|
813
|
-
# Save to disk if persistence is enabled
|
|
814
|
-
if server.scenario_persistence:
|
|
815
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
816
|
-
|
|
817
|
-
return ScenarioResponse(
|
|
818
|
-
scope="location", identifier=location, scenario=scenario
|
|
819
|
-
)
|
|
820
|
-
|
|
821
|
-
@app.delete(
|
|
822
|
-
"/api/scenarios/locations/{location}",
|
|
823
|
-
status_code=204,
|
|
824
|
-
tags=["scenarios"],
|
|
825
|
-
summary="Clear location-specific scenario",
|
|
826
|
-
description="Clears the scenario for the specified location.",
|
|
827
|
-
responses={404: {"description": "Location scenario not found"}},
|
|
828
|
-
)
|
|
829
|
-
async def clear_location_scenario(location: str):
|
|
830
|
-
"""Clear location-specific scenario."""
|
|
831
|
-
if not server.scenario_manager.delete_location_scenario(location):
|
|
832
|
-
raise HTTPException(
|
|
833
|
-
status_code=404, detail=f"No scenario found for location {location}"
|
|
834
|
-
)
|
|
835
|
-
|
|
836
|
-
# Invalidate cache for all devices
|
|
837
|
-
for device in server.get_all_devices():
|
|
838
|
-
device.invalidate_scenario_cache()
|
|
839
|
-
|
|
840
|
-
# Save to disk if persistence is enabled
|
|
841
|
-
if server.scenario_persistence:
|
|
842
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
843
|
-
|
|
844
|
-
@app.get(
|
|
845
|
-
"/api/scenarios/groups/{group}",
|
|
846
|
-
response_model=ScenarioResponse,
|
|
847
|
-
tags=["scenarios"],
|
|
848
|
-
summary="Get group-specific scenario",
|
|
849
|
-
description="Returns the scenario for a specific group.",
|
|
850
|
-
responses={404: {"description": "Group scenario not found"}},
|
|
851
|
-
)
|
|
852
|
-
async def get_group_scenario(group: str):
|
|
853
|
-
"""Get group-specific scenario."""
|
|
854
|
-
config = server.scenario_manager.get_group_scenario(group)
|
|
855
|
-
if config is None:
|
|
856
|
-
raise HTTPException(
|
|
857
|
-
status_code=404, detail=f"No scenario found for group {group}"
|
|
858
|
-
)
|
|
859
|
-
return ScenarioResponse(
|
|
860
|
-
scope="group", identifier=group, scenario=_scenario_config_to_model(config)
|
|
861
|
-
)
|
|
862
|
-
|
|
863
|
-
@app.put(
|
|
864
|
-
"/api/scenarios/groups/{group}",
|
|
865
|
-
response_model=ScenarioResponse,
|
|
866
|
-
tags=["scenarios"],
|
|
867
|
-
summary="Set group-specific scenario",
|
|
868
|
-
description=(
|
|
869
|
-
"Sets a scenario that applies to all devices in a specific group."
|
|
870
|
-
),
|
|
871
|
-
)
|
|
872
|
-
async def set_group_scenario(group: str, scenario: ScenarioConfigModel):
|
|
873
|
-
"""Set group-specific scenario."""
|
|
874
|
-
config = _model_to_scenario_config(scenario)
|
|
875
|
-
server.scenario_manager.set_group_scenario(group, config)
|
|
876
|
-
|
|
877
|
-
# Invalidate cache for all devices
|
|
878
|
-
for device in server.get_all_devices():
|
|
879
|
-
device.invalidate_scenario_cache()
|
|
880
|
-
|
|
881
|
-
# Save to disk if persistence is enabled
|
|
882
|
-
if server.scenario_persistence:
|
|
883
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
884
|
-
|
|
885
|
-
return ScenarioResponse(scope="group", identifier=group, scenario=scenario)
|
|
886
|
-
|
|
887
|
-
@app.delete(
|
|
888
|
-
"/api/scenarios/groups/{group}",
|
|
889
|
-
status_code=204,
|
|
890
|
-
tags=["scenarios"],
|
|
891
|
-
summary="Clear group-specific scenario",
|
|
892
|
-
description="Clears the scenario for the specified group.",
|
|
893
|
-
responses={404: {"description": "Group scenario not found"}},
|
|
894
|
-
)
|
|
895
|
-
async def clear_group_scenario(group: str):
|
|
896
|
-
"""Clear group-specific scenario."""
|
|
897
|
-
if not server.scenario_manager.delete_group_scenario(group):
|
|
898
|
-
raise HTTPException(
|
|
899
|
-
status_code=404, detail=f"No scenario found for group {group}"
|
|
900
|
-
)
|
|
901
|
-
|
|
902
|
-
# Invalidate cache for all devices
|
|
903
|
-
for device in server.get_all_devices():
|
|
904
|
-
device.invalidate_scenario_cache()
|
|
905
|
-
|
|
906
|
-
# Save to disk if persistence is enabled
|
|
907
|
-
if server.scenario_persistence:
|
|
908
|
-
server.scenario_persistence.save(server.scenario_manager)
|
|
909
|
-
|
|
910
|
-
return app
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
async def run_api_server(
|
|
914
|
-
server: EmulatedLifxServer, host: str = "127.0.0.1", port: int = 8080
|
|
915
|
-
):
|
|
916
|
-
"""Run the FastAPI server.
|
|
917
|
-
|
|
918
|
-
Args:
|
|
919
|
-
server: The LIFX emulator server instance
|
|
920
|
-
host: Host to bind to
|
|
921
|
-
port: Port to bind to
|
|
922
|
-
"""
|
|
923
|
-
import uvicorn
|
|
924
|
-
|
|
925
|
-
app = create_api_app(server)
|
|
926
|
-
|
|
927
|
-
config = uvicorn.Config(
|
|
928
|
-
app,
|
|
929
|
-
host=host,
|
|
930
|
-
port=port,
|
|
931
|
-
log_level="info",
|
|
932
|
-
access_log=True,
|
|
933
|
-
)
|
|
934
|
-
api_server = uvicorn.Server(config)
|
|
935
|
-
|
|
936
|
-
logger.info("Starting API server on http://%s:%s", host, port)
|
|
937
|
-
await api_server.serve()
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
# Embedded web UI
|
|
941
|
-
HTML_UI = """
|
|
942
1
|
<!DOCTYPE html>
|
|
943
2
|
<html lang="en">
|
|
944
3
|
<head>
|
|
@@ -1838,4 +897,3 @@ HTML_UI = """
|
|
|
1838
897
|
</script>
|
|
1839
898
|
</body>
|
|
1840
899
|
</html>
|
|
1841
|
-
"""
|