lifx-emulator 1.0.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 +31 -0
- lifx_emulator/__main__.py +607 -0
- lifx_emulator/api.py +1825 -0
- lifx_emulator/async_storage.py +308 -0
- lifx_emulator/constants.py +33 -0
- lifx_emulator/device.py +750 -0
- lifx_emulator/device_states.py +114 -0
- lifx_emulator/factories.py +380 -0
- lifx_emulator/handlers/__init__.py +39 -0
- lifx_emulator/handlers/base.py +49 -0
- lifx_emulator/handlers/device_handlers.py +340 -0
- lifx_emulator/handlers/light_handlers.py +372 -0
- lifx_emulator/handlers/multizone_handlers.py +249 -0
- lifx_emulator/handlers/registry.py +110 -0
- lifx_emulator/handlers/tile_handlers.py +309 -0
- lifx_emulator/observers.py +139 -0
- lifx_emulator/products/__init__.py +28 -0
- lifx_emulator/products/generator.py +771 -0
- lifx_emulator/products/registry.py +1446 -0
- lifx_emulator/products/specs.py +242 -0
- lifx_emulator/products/specs.yml +327 -0
- lifx_emulator/protocol/__init__.py +1 -0
- lifx_emulator/protocol/base.py +334 -0
- lifx_emulator/protocol/const.py +8 -0
- lifx_emulator/protocol/generator.py +1371 -0
- lifx_emulator/protocol/header.py +159 -0
- lifx_emulator/protocol/packets.py +1351 -0
- lifx_emulator/protocol/protocol_types.py +844 -0
- lifx_emulator/protocol/serializer.py +379 -0
- lifx_emulator/scenario_manager.py +402 -0
- lifx_emulator/scenario_persistence.py +206 -0
- lifx_emulator/server.py +482 -0
- lifx_emulator/state_restorer.py +259 -0
- lifx_emulator/state_serializer.py +130 -0
- lifx_emulator/storage_protocol.py +100 -0
- lifx_emulator-1.0.0.dist-info/METADATA +445 -0
- lifx_emulator-1.0.0.dist-info/RECORD +40 -0
- lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
- lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
- lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
lifx_emulator/api.py
ADDED
|
@@ -0,0 +1,1825 @@
|
|
|
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
|
|
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
|
+
|
|
137
|
+
class ScenarioResponse(BaseModel):
|
|
138
|
+
"""Response model for scenario operations."""
|
|
139
|
+
|
|
140
|
+
scope: str = Field(
|
|
141
|
+
..., description="Scope of the scenario (global, device, type, location, group)"
|
|
142
|
+
)
|
|
143
|
+
identifier: str | None = Field(
|
|
144
|
+
None, description="Identifier for the scope (serial, type name, etc.)"
|
|
145
|
+
)
|
|
146
|
+
scenario: ScenarioConfigModel = Field(..., description="The scenario configuration")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def create_api_app(server: EmulatedLifxServer) -> FastAPI:
|
|
150
|
+
"""Create FastAPI application for emulator management.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
server: The LIFX emulator server instance
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
FastAPI application
|
|
157
|
+
"""
|
|
158
|
+
app = FastAPI(
|
|
159
|
+
title="LIFX Emulator API",
|
|
160
|
+
description="""
|
|
161
|
+
Runtime management and monitoring API for LIFX device emulator.
|
|
162
|
+
|
|
163
|
+
This API provides read-only monitoring of the emulator state and device management
|
|
164
|
+
capabilities (add/remove devices). Device state changes must be performed via the
|
|
165
|
+
LIFX LAN protocol.
|
|
166
|
+
|
|
167
|
+
## Features
|
|
168
|
+
- Real-time server statistics and packet monitoring
|
|
169
|
+
- Device inspection and management
|
|
170
|
+
- Recent activity tracking
|
|
171
|
+
- OpenAPI 3.1.0 compliant schema
|
|
172
|
+
""",
|
|
173
|
+
version="1.0.0",
|
|
174
|
+
contact={
|
|
175
|
+
"name": "LIFX Emulator",
|
|
176
|
+
"url": "https://github.com/Djelibeybi/lifx-emulator",
|
|
177
|
+
},
|
|
178
|
+
license_info={
|
|
179
|
+
"name": "UPL-1.0",
|
|
180
|
+
"url": "https://opensource.org/licenses/UPL",
|
|
181
|
+
},
|
|
182
|
+
openapi_tags=[
|
|
183
|
+
{
|
|
184
|
+
"name": "monitoring",
|
|
185
|
+
"description": "Server statistics and activity monitoring",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"name": "devices",
|
|
189
|
+
"description": "Device management and inspection",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"name": "scenarios",
|
|
193
|
+
"description": (
|
|
194
|
+
"Test scenario management for simulating device behaviors"
|
|
195
|
+
),
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
201
|
+
async def root():
|
|
202
|
+
"""Serve web UI."""
|
|
203
|
+
return HTML_UI
|
|
204
|
+
|
|
205
|
+
@app.get(
|
|
206
|
+
"/api/stats",
|
|
207
|
+
response_model=ServerStats,
|
|
208
|
+
tags=["monitoring"],
|
|
209
|
+
summary="Get server statistics",
|
|
210
|
+
description=(
|
|
211
|
+
"Returns server uptime, packet counts, error counts, and device count."
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
async def get_stats():
|
|
215
|
+
"""Get server statistics."""
|
|
216
|
+
return server.get_stats()
|
|
217
|
+
|
|
218
|
+
@app.get(
|
|
219
|
+
"/api/devices",
|
|
220
|
+
response_model=list[DeviceInfo],
|
|
221
|
+
tags=["devices"],
|
|
222
|
+
summary="List all devices",
|
|
223
|
+
description=(
|
|
224
|
+
"Returns a list of all emulated devices with their current configuration."
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
async def list_devices():
|
|
228
|
+
"""List all emulated devices."""
|
|
229
|
+
devices = server.get_all_devices()
|
|
230
|
+
result = []
|
|
231
|
+
for dev in devices:
|
|
232
|
+
device_info = DeviceInfo(
|
|
233
|
+
serial=dev.state.serial,
|
|
234
|
+
label=dev.state.label,
|
|
235
|
+
product=dev.state.product,
|
|
236
|
+
vendor=dev.state.vendor,
|
|
237
|
+
power_level=dev.state.power_level,
|
|
238
|
+
has_color=dev.state.has_color,
|
|
239
|
+
has_infrared=dev.state.has_infrared,
|
|
240
|
+
has_multizone=dev.state.has_multizone,
|
|
241
|
+
has_extended_multizone=dev.state.has_extended_multizone,
|
|
242
|
+
has_matrix=dev.state.has_matrix,
|
|
243
|
+
has_hev=dev.state.has_hev,
|
|
244
|
+
zone_count=dev.state.multizone.zone_count
|
|
245
|
+
if dev.state.multizone is not None
|
|
246
|
+
else 0,
|
|
247
|
+
tile_count=dev.state.matrix.tile_count
|
|
248
|
+
if dev.state.matrix is not None
|
|
249
|
+
else 0,
|
|
250
|
+
color=ColorHsbk(
|
|
251
|
+
hue=dev.state.color.hue,
|
|
252
|
+
saturation=dev.state.color.saturation,
|
|
253
|
+
brightness=dev.state.color.brightness,
|
|
254
|
+
kelvin=dev.state.color.kelvin,
|
|
255
|
+
)
|
|
256
|
+
if dev.state.has_color
|
|
257
|
+
else None,
|
|
258
|
+
zone_colors=[
|
|
259
|
+
ColorHsbk(
|
|
260
|
+
hue=c.hue,
|
|
261
|
+
saturation=c.saturation,
|
|
262
|
+
brightness=c.brightness,
|
|
263
|
+
kelvin=c.kelvin,
|
|
264
|
+
)
|
|
265
|
+
for c in dev.state.multizone.zone_colors
|
|
266
|
+
]
|
|
267
|
+
if dev.state.multizone is not None
|
|
268
|
+
else [],
|
|
269
|
+
tile_devices=dev.state.matrix.tile_devices
|
|
270
|
+
if dev.state.matrix is not None
|
|
271
|
+
else [],
|
|
272
|
+
version_major=dev.state.version_major,
|
|
273
|
+
version_minor=dev.state.version_minor,
|
|
274
|
+
build_timestamp=dev.state.build_timestamp,
|
|
275
|
+
group_label=dev.state.group.group_label,
|
|
276
|
+
location_label=dev.state.location.location_label,
|
|
277
|
+
uptime_ns=dev.state.uptime_ns,
|
|
278
|
+
wifi_signal=dev.state.wifi_signal,
|
|
279
|
+
)
|
|
280
|
+
result.append(device_info)
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
@app.get(
|
|
284
|
+
"/api/devices/{serial}",
|
|
285
|
+
response_model=DeviceInfo,
|
|
286
|
+
tags=["devices"],
|
|
287
|
+
summary="Get device information",
|
|
288
|
+
description=(
|
|
289
|
+
"Returns detailed information about a specific device by its serial number."
|
|
290
|
+
),
|
|
291
|
+
responses={
|
|
292
|
+
404: {"description": "Device not found"},
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
async def get_device(serial: str):
|
|
296
|
+
"""Get specific device information."""
|
|
297
|
+
device = server.get_device(serial)
|
|
298
|
+
if not device:
|
|
299
|
+
raise HTTPException(status_code=404, detail=f"Device {serial} not found")
|
|
300
|
+
|
|
301
|
+
return DeviceInfo(
|
|
302
|
+
serial=device.state.serial,
|
|
303
|
+
label=device.state.label,
|
|
304
|
+
product=device.state.product,
|
|
305
|
+
vendor=device.state.vendor,
|
|
306
|
+
power_level=device.state.power_level,
|
|
307
|
+
has_color=device.state.has_color,
|
|
308
|
+
has_infrared=device.state.has_infrared,
|
|
309
|
+
has_multizone=device.state.has_multizone,
|
|
310
|
+
has_extended_multizone=device.state.has_extended_multizone,
|
|
311
|
+
has_matrix=device.state.has_matrix,
|
|
312
|
+
has_hev=device.state.has_hev,
|
|
313
|
+
zone_count=device.state.multizone.zone_count
|
|
314
|
+
if device.state.multizone is not None
|
|
315
|
+
else 0,
|
|
316
|
+
tile_count=device.state.matrix.tile_count
|
|
317
|
+
if device.state.matrix is not None
|
|
318
|
+
else 0,
|
|
319
|
+
color=ColorHsbk(
|
|
320
|
+
hue=device.state.color.hue,
|
|
321
|
+
saturation=device.state.color.saturation,
|
|
322
|
+
brightness=device.state.color.brightness,
|
|
323
|
+
kelvin=device.state.color.kelvin,
|
|
324
|
+
)
|
|
325
|
+
if device.state.has_color
|
|
326
|
+
else None,
|
|
327
|
+
zone_colors=[
|
|
328
|
+
ColorHsbk(
|
|
329
|
+
hue=c.hue,
|
|
330
|
+
saturation=c.saturation,
|
|
331
|
+
brightness=c.brightness,
|
|
332
|
+
kelvin=c.kelvin,
|
|
333
|
+
)
|
|
334
|
+
for c in device.state.multizone.zone_colors
|
|
335
|
+
]
|
|
336
|
+
if device.state.multizone is not None
|
|
337
|
+
else [],
|
|
338
|
+
tile_devices=device.state.matrix.tile_devices
|
|
339
|
+
if device.state.matrix is not None
|
|
340
|
+
else [],
|
|
341
|
+
version_major=device.state.version_major,
|
|
342
|
+
version_minor=device.state.version_minor,
|
|
343
|
+
build_timestamp=device.state.build_timestamp,
|
|
344
|
+
group_label=device.state.group.group_label,
|
|
345
|
+
location_label=device.state.location.location_label,
|
|
346
|
+
uptime_ns=device.state.uptime_ns,
|
|
347
|
+
wifi_signal=device.state.wifi_signal,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@app.post(
|
|
351
|
+
"/api/devices",
|
|
352
|
+
response_model=DeviceInfo,
|
|
353
|
+
status_code=201,
|
|
354
|
+
tags=["devices"],
|
|
355
|
+
summary="Create a new device",
|
|
356
|
+
description=(
|
|
357
|
+
"Creates a new emulated device by product ID. "
|
|
358
|
+
"The device will be added to the emulator immediately."
|
|
359
|
+
),
|
|
360
|
+
responses={
|
|
361
|
+
201: {"description": "Device created successfully"},
|
|
362
|
+
400: {"description": "Invalid product ID or parameters"},
|
|
363
|
+
409: {"description": "Device with this serial already exists"},
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
async def create_device(request: DeviceCreateRequest):
|
|
367
|
+
"""Create a new device."""
|
|
368
|
+
from lifx_emulator.factories import create_device
|
|
369
|
+
|
|
370
|
+
# Build firmware_version tuple if both major and minor are provided
|
|
371
|
+
firmware_version = None
|
|
372
|
+
if request.firmware_major is not None and request.firmware_minor is not None:
|
|
373
|
+
firmware_version = (request.firmware_major, request.firmware_minor)
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
device = create_device(
|
|
377
|
+
product_id=request.product_id,
|
|
378
|
+
serial=request.serial,
|
|
379
|
+
zone_count=request.zone_count,
|
|
380
|
+
tile_count=request.tile_count,
|
|
381
|
+
tile_width=request.tile_width,
|
|
382
|
+
tile_height=request.tile_height,
|
|
383
|
+
firmware_version=firmware_version,
|
|
384
|
+
storage=server.storage,
|
|
385
|
+
)
|
|
386
|
+
except Exception as e:
|
|
387
|
+
raise HTTPException(status_code=400, detail=f"Failed to create device: {e}")
|
|
388
|
+
|
|
389
|
+
if not server.add_device(device):
|
|
390
|
+
raise HTTPException(
|
|
391
|
+
status_code=409,
|
|
392
|
+
detail=f"Device with serial {device.state.serial} already exists",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return DeviceInfo(
|
|
396
|
+
serial=device.state.serial,
|
|
397
|
+
label=device.state.label,
|
|
398
|
+
product=device.state.product,
|
|
399
|
+
vendor=device.state.vendor,
|
|
400
|
+
power_level=device.state.power_level,
|
|
401
|
+
has_color=device.state.has_color,
|
|
402
|
+
has_infrared=device.state.has_infrared,
|
|
403
|
+
has_multizone=device.state.has_multizone,
|
|
404
|
+
has_extended_multizone=device.state.has_extended_multizone,
|
|
405
|
+
has_matrix=device.state.has_matrix,
|
|
406
|
+
has_hev=device.state.has_hev,
|
|
407
|
+
zone_count=device.state.multizone.zone_count
|
|
408
|
+
if device.state.multizone is not None
|
|
409
|
+
else 0,
|
|
410
|
+
tile_count=device.state.matrix.tile_count
|
|
411
|
+
if device.state.matrix is not None
|
|
412
|
+
else 0,
|
|
413
|
+
color=ColorHsbk(
|
|
414
|
+
hue=device.state.color.hue,
|
|
415
|
+
saturation=device.state.color.saturation,
|
|
416
|
+
brightness=device.state.color.brightness,
|
|
417
|
+
kelvin=device.state.color.kelvin,
|
|
418
|
+
)
|
|
419
|
+
if device.state.has_color
|
|
420
|
+
else None,
|
|
421
|
+
zone_colors=[
|
|
422
|
+
ColorHsbk(
|
|
423
|
+
hue=c.hue,
|
|
424
|
+
saturation=c.saturation,
|
|
425
|
+
brightness=c.brightness,
|
|
426
|
+
kelvin=c.kelvin,
|
|
427
|
+
)
|
|
428
|
+
for c in device.state.multizone.zone_colors
|
|
429
|
+
]
|
|
430
|
+
if device.state.multizone is not None
|
|
431
|
+
else [],
|
|
432
|
+
tile_devices=device.state.matrix.tile_devices
|
|
433
|
+
if device.state.matrix is not None
|
|
434
|
+
else [],
|
|
435
|
+
version_major=device.state.version_major,
|
|
436
|
+
version_minor=device.state.version_minor,
|
|
437
|
+
build_timestamp=device.state.build_timestamp,
|
|
438
|
+
group_label=device.state.group.group_label,
|
|
439
|
+
location_label=device.state.location.location_label,
|
|
440
|
+
uptime_ns=device.state.uptime_ns,
|
|
441
|
+
wifi_signal=device.state.wifi_signal,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
@app.delete(
|
|
445
|
+
"/api/devices/{serial}",
|
|
446
|
+
status_code=204,
|
|
447
|
+
tags=["devices"],
|
|
448
|
+
summary="Delete a device",
|
|
449
|
+
description=(
|
|
450
|
+
"Removes an emulated device from the server. "
|
|
451
|
+
"The device will stop responding to LIFX protocol packets."
|
|
452
|
+
),
|
|
453
|
+
responses={
|
|
454
|
+
204: {"description": "Device deleted successfully"},
|
|
455
|
+
404: {"description": "Device not found"},
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
async def delete_device(serial: str):
|
|
459
|
+
"""Delete a device."""
|
|
460
|
+
if not server.remove_device(serial):
|
|
461
|
+
raise HTTPException(status_code=404, detail=f"Device {serial} not found")
|
|
462
|
+
|
|
463
|
+
@app.delete(
|
|
464
|
+
"/api/devices",
|
|
465
|
+
status_code=200,
|
|
466
|
+
tags=["devices"],
|
|
467
|
+
summary="Delete all devices",
|
|
468
|
+
description=(
|
|
469
|
+
"Removes all emulated devices from the server. "
|
|
470
|
+
"All devices will stop responding to LIFX protocol packets."
|
|
471
|
+
),
|
|
472
|
+
responses={
|
|
473
|
+
200: {"description": "All devices deleted successfully"},
|
|
474
|
+
},
|
|
475
|
+
)
|
|
476
|
+
async def delete_all_devices():
|
|
477
|
+
"""Delete all devices from the running server."""
|
|
478
|
+
count = server.remove_all_devices(delete_storage=False)
|
|
479
|
+
return {"deleted": count, "message": f"Removed {count} device(s) from server"}
|
|
480
|
+
|
|
481
|
+
@app.delete(
|
|
482
|
+
"/api/storage",
|
|
483
|
+
status_code=200,
|
|
484
|
+
tags=["devices"],
|
|
485
|
+
summary="Clear persistent storage",
|
|
486
|
+
description=(
|
|
487
|
+
"Deletes all persistent device state files from disk. "
|
|
488
|
+
"This does not affect currently running devices, only saved state files."
|
|
489
|
+
),
|
|
490
|
+
responses={
|
|
491
|
+
200: {"description": "Storage cleared successfully"},
|
|
492
|
+
503: {"description": "Persistent storage not enabled"},
|
|
493
|
+
},
|
|
494
|
+
)
|
|
495
|
+
async def clear_storage():
|
|
496
|
+
"""Clear all persistent device state from storage."""
|
|
497
|
+
if not server.storage:
|
|
498
|
+
raise HTTPException(
|
|
499
|
+
status_code=503, detail="Persistent storage is not enabled"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
deleted = server.storage.delete_all_device_states()
|
|
503
|
+
return {
|
|
504
|
+
"deleted": deleted,
|
|
505
|
+
"message": f"Deleted {deleted} device state(s) from persistent storage",
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
@app.get(
|
|
509
|
+
"/api/activity",
|
|
510
|
+
response_model=list[ActivityEvent],
|
|
511
|
+
tags=["monitoring"],
|
|
512
|
+
summary="Get recent activity",
|
|
513
|
+
description=(
|
|
514
|
+
"Returns the last 100 packet events (TX/RX) "
|
|
515
|
+
"with timestamps and packet details."
|
|
516
|
+
),
|
|
517
|
+
)
|
|
518
|
+
async def get_activity():
|
|
519
|
+
"""Get recent activity events."""
|
|
520
|
+
return [ActivityEvent(**event) for event in server.get_recent_activity()]
|
|
521
|
+
|
|
522
|
+
# Scenario Management Endpoints
|
|
523
|
+
|
|
524
|
+
def _scenario_config_to_model(config) -> ScenarioConfigModel:
|
|
525
|
+
"""Convert ScenarioConfig to Pydantic model."""
|
|
526
|
+
from lifx_emulator.scenario_manager import ScenarioConfig
|
|
527
|
+
|
|
528
|
+
if isinstance(config, ScenarioConfig):
|
|
529
|
+
return ScenarioConfigModel(
|
|
530
|
+
drop_packets=config.drop_packets,
|
|
531
|
+
response_delays=config.response_delays,
|
|
532
|
+
malformed_packets=config.malformed_packets,
|
|
533
|
+
invalid_field_values=config.invalid_field_values,
|
|
534
|
+
firmware_version=config.firmware_version,
|
|
535
|
+
partial_responses=config.partial_responses,
|
|
536
|
+
send_unhandled=config.send_unhandled,
|
|
537
|
+
)
|
|
538
|
+
return ScenarioConfigModel(**config)
|
|
539
|
+
|
|
540
|
+
def _model_to_scenario_config(model: ScenarioConfigModel):
|
|
541
|
+
"""Convert Pydantic model to ScenarioConfig."""
|
|
542
|
+
from lifx_emulator.scenario_manager import ScenarioConfig
|
|
543
|
+
|
|
544
|
+
return ScenarioConfig(
|
|
545
|
+
drop_packets=model.drop_packets,
|
|
546
|
+
response_delays=model.response_delays,
|
|
547
|
+
malformed_packets=model.malformed_packets,
|
|
548
|
+
invalid_field_values=model.invalid_field_values,
|
|
549
|
+
firmware_version=model.firmware_version,
|
|
550
|
+
partial_responses=model.partial_responses,
|
|
551
|
+
send_unhandled=model.send_unhandled,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
@app.get(
|
|
555
|
+
"/api/scenarios/global",
|
|
556
|
+
response_model=ScenarioResponse,
|
|
557
|
+
tags=["scenarios"],
|
|
558
|
+
summary="Get global scenario",
|
|
559
|
+
description=(
|
|
560
|
+
"Returns the global scenario that applies to all devices as a baseline."
|
|
561
|
+
),
|
|
562
|
+
)
|
|
563
|
+
async def get_global_scenario():
|
|
564
|
+
"""Get global scenario configuration."""
|
|
565
|
+
config = server.scenario_manager.get_global_scenario()
|
|
566
|
+
return ScenarioResponse(
|
|
567
|
+
scope="global", identifier=None, scenario=_scenario_config_to_model(config)
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
@app.put(
|
|
571
|
+
"/api/scenarios/global",
|
|
572
|
+
response_model=ScenarioResponse,
|
|
573
|
+
tags=["scenarios"],
|
|
574
|
+
summary="Set global scenario",
|
|
575
|
+
description=(
|
|
576
|
+
"Sets the global scenario that applies to all devices as a baseline."
|
|
577
|
+
),
|
|
578
|
+
)
|
|
579
|
+
async def set_global_scenario(scenario: ScenarioConfigModel):
|
|
580
|
+
"""Set global scenario configuration."""
|
|
581
|
+
config = _model_to_scenario_config(scenario)
|
|
582
|
+
server.scenario_manager.set_global_scenario(config)
|
|
583
|
+
|
|
584
|
+
# Invalidate cache for all devices
|
|
585
|
+
for device in server.get_all_devices():
|
|
586
|
+
device.invalidate_scenario_cache()
|
|
587
|
+
|
|
588
|
+
# Save to disk if persistence is enabled
|
|
589
|
+
if server.scenario_persistence:
|
|
590
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
591
|
+
|
|
592
|
+
return ScenarioResponse(scope="global", identifier=None, scenario=scenario)
|
|
593
|
+
|
|
594
|
+
@app.delete(
|
|
595
|
+
"/api/scenarios/global",
|
|
596
|
+
status_code=204,
|
|
597
|
+
tags=["scenarios"],
|
|
598
|
+
summary="Clear global scenario",
|
|
599
|
+
description="Clears the global scenario, resetting it to defaults.",
|
|
600
|
+
)
|
|
601
|
+
async def clear_global_scenario():
|
|
602
|
+
"""Clear global scenario configuration."""
|
|
603
|
+
server.scenario_manager.clear_global_scenario()
|
|
604
|
+
|
|
605
|
+
# Invalidate cache for all devices
|
|
606
|
+
for device in server.get_all_devices():
|
|
607
|
+
device.invalidate_scenario_cache()
|
|
608
|
+
|
|
609
|
+
# Save to disk if persistence is enabled
|
|
610
|
+
if server.scenario_persistence:
|
|
611
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
612
|
+
|
|
613
|
+
@app.get(
|
|
614
|
+
"/api/scenarios/devices/{serial}",
|
|
615
|
+
response_model=ScenarioResponse,
|
|
616
|
+
tags=["scenarios"],
|
|
617
|
+
summary="Get device-specific scenario",
|
|
618
|
+
description=(
|
|
619
|
+
"Returns the scenario configuration for a specific device by serial number."
|
|
620
|
+
),
|
|
621
|
+
responses={404: {"description": "Device scenario not found"}},
|
|
622
|
+
)
|
|
623
|
+
async def get_device_scenario(serial: str):
|
|
624
|
+
"""Get device-specific scenario."""
|
|
625
|
+
config = server.scenario_manager.get_device_scenario(serial)
|
|
626
|
+
if config is None:
|
|
627
|
+
raise HTTPException(
|
|
628
|
+
status_code=404, detail=f"No scenario found for device {serial}"
|
|
629
|
+
)
|
|
630
|
+
return ScenarioResponse(
|
|
631
|
+
scope="device",
|
|
632
|
+
identifier=serial,
|
|
633
|
+
scenario=_scenario_config_to_model(config),
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
@app.put(
|
|
637
|
+
"/api/scenarios/devices/{serial}",
|
|
638
|
+
response_model=ScenarioResponse,
|
|
639
|
+
tags=["scenarios"],
|
|
640
|
+
summary="Set device-specific scenario",
|
|
641
|
+
description="Sets a scenario that applies only to the specified device.",
|
|
642
|
+
)
|
|
643
|
+
async def set_device_scenario(serial: str, scenario: ScenarioConfigModel):
|
|
644
|
+
"""Set device-specific scenario."""
|
|
645
|
+
# Verify device exists
|
|
646
|
+
device = server.get_device(serial)
|
|
647
|
+
if not device:
|
|
648
|
+
raise HTTPException(status_code=404, detail=f"Device {serial} not found")
|
|
649
|
+
|
|
650
|
+
config = _model_to_scenario_config(scenario)
|
|
651
|
+
server.scenario_manager.set_device_scenario(serial, config)
|
|
652
|
+
|
|
653
|
+
# Invalidate cache for this device
|
|
654
|
+
device.invalidate_scenario_cache()
|
|
655
|
+
|
|
656
|
+
# Save to disk if persistence is enabled
|
|
657
|
+
if server.scenario_persistence:
|
|
658
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
659
|
+
|
|
660
|
+
return ScenarioResponse(scope="device", identifier=serial, scenario=scenario)
|
|
661
|
+
|
|
662
|
+
@app.delete(
|
|
663
|
+
"/api/scenarios/devices/{serial}",
|
|
664
|
+
status_code=204,
|
|
665
|
+
tags=["scenarios"],
|
|
666
|
+
summary="Clear device-specific scenario",
|
|
667
|
+
description="Clears the scenario for the specified device.",
|
|
668
|
+
responses={404: {"description": "Device scenario not found"}},
|
|
669
|
+
)
|
|
670
|
+
async def clear_device_scenario(serial: str):
|
|
671
|
+
"""Clear device-specific scenario."""
|
|
672
|
+
if not server.scenario_manager.delete_device_scenario(serial):
|
|
673
|
+
raise HTTPException(
|
|
674
|
+
status_code=404, detail=f"No scenario found for device {serial}"
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# Invalidate cache if device exists
|
|
678
|
+
device = server.get_device(serial)
|
|
679
|
+
if device:
|
|
680
|
+
device.invalidate_scenario_cache()
|
|
681
|
+
|
|
682
|
+
# Save to disk if persistence is enabled
|
|
683
|
+
if server.scenario_persistence:
|
|
684
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
685
|
+
|
|
686
|
+
@app.get(
|
|
687
|
+
"/api/scenarios/types/{device_type}",
|
|
688
|
+
response_model=ScenarioResponse,
|
|
689
|
+
tags=["scenarios"],
|
|
690
|
+
summary="Get type-specific scenario",
|
|
691
|
+
description=(
|
|
692
|
+
"Returns the scenario for a device type (matrix, multizone, color, etc.)."
|
|
693
|
+
),
|
|
694
|
+
responses={404: {"description": "Type scenario not found"}},
|
|
695
|
+
)
|
|
696
|
+
async def get_type_scenario(device_type: str):
|
|
697
|
+
"""Get type-specific scenario."""
|
|
698
|
+
config = server.scenario_manager.get_type_scenario(device_type)
|
|
699
|
+
if config is None:
|
|
700
|
+
raise HTTPException(
|
|
701
|
+
status_code=404, detail=f"No scenario found for type {device_type}"
|
|
702
|
+
)
|
|
703
|
+
return ScenarioResponse(
|
|
704
|
+
scope="type",
|
|
705
|
+
identifier=device_type,
|
|
706
|
+
scenario=_scenario_config_to_model(config),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
@app.put(
|
|
710
|
+
"/api/scenarios/types/{device_type}",
|
|
711
|
+
response_model=ScenarioResponse,
|
|
712
|
+
tags=["scenarios"],
|
|
713
|
+
summary="Set type-specific scenario",
|
|
714
|
+
description=(
|
|
715
|
+
"Sets a scenario that applies to all devices "
|
|
716
|
+
"of a specific type. "
|
|
717
|
+
"Valid types: matrix, multizone, color, infrared, hev"
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
async def set_type_scenario(device_type: str, scenario: ScenarioConfigModel):
|
|
721
|
+
"""Set type-specific scenario."""
|
|
722
|
+
config = _model_to_scenario_config(scenario)
|
|
723
|
+
server.scenario_manager.set_type_scenario(device_type, config)
|
|
724
|
+
|
|
725
|
+
# Invalidate cache for all devices
|
|
726
|
+
for device in server.get_all_devices():
|
|
727
|
+
device.invalidate_scenario_cache()
|
|
728
|
+
|
|
729
|
+
# Save to disk if persistence is enabled
|
|
730
|
+
if server.scenario_persistence:
|
|
731
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
732
|
+
|
|
733
|
+
return ScenarioResponse(scope="type", identifier=device_type, scenario=scenario)
|
|
734
|
+
|
|
735
|
+
@app.delete(
|
|
736
|
+
"/api/scenarios/types/{device_type}",
|
|
737
|
+
status_code=204,
|
|
738
|
+
tags=["scenarios"],
|
|
739
|
+
summary="Clear type-specific scenario",
|
|
740
|
+
description="Clears the scenario for the specified device type.",
|
|
741
|
+
responses={404: {"description": "Type scenario not found"}},
|
|
742
|
+
)
|
|
743
|
+
async def clear_type_scenario(device_type: str):
|
|
744
|
+
"""Clear type-specific scenario."""
|
|
745
|
+
if not server.scenario_manager.delete_type_scenario(device_type):
|
|
746
|
+
raise HTTPException(
|
|
747
|
+
status_code=404, detail=f"No scenario found for type {device_type}"
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
# Invalidate cache for all devices
|
|
751
|
+
for device in server.get_all_devices():
|
|
752
|
+
device.invalidate_scenario_cache()
|
|
753
|
+
|
|
754
|
+
# Save to disk if persistence is enabled
|
|
755
|
+
if server.scenario_persistence:
|
|
756
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
757
|
+
|
|
758
|
+
@app.get(
|
|
759
|
+
"/api/scenarios/locations/{location}",
|
|
760
|
+
response_model=ScenarioResponse,
|
|
761
|
+
tags=["scenarios"],
|
|
762
|
+
summary="Get location-specific scenario",
|
|
763
|
+
description="Returns the scenario for a specific location.",
|
|
764
|
+
responses={404: {"description": "Location scenario not found"}},
|
|
765
|
+
)
|
|
766
|
+
async def get_location_scenario(location: str):
|
|
767
|
+
"""Get location-specific scenario."""
|
|
768
|
+
config = server.scenario_manager.get_location_scenario(location)
|
|
769
|
+
if config is None:
|
|
770
|
+
raise HTTPException(
|
|
771
|
+
status_code=404, detail=f"No scenario found for location {location}"
|
|
772
|
+
)
|
|
773
|
+
return ScenarioResponse(
|
|
774
|
+
scope="location",
|
|
775
|
+
identifier=location,
|
|
776
|
+
scenario=_scenario_config_to_model(config),
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
@app.put(
|
|
780
|
+
"/api/scenarios/locations/{location}",
|
|
781
|
+
response_model=ScenarioResponse,
|
|
782
|
+
tags=["scenarios"],
|
|
783
|
+
summary="Set location-specific scenario",
|
|
784
|
+
description=(
|
|
785
|
+
"Sets a scenario that applies to all devices in a specific location."
|
|
786
|
+
),
|
|
787
|
+
)
|
|
788
|
+
async def set_location_scenario(location: str, scenario: ScenarioConfigModel):
|
|
789
|
+
"""Set location-specific scenario."""
|
|
790
|
+
config = _model_to_scenario_config(scenario)
|
|
791
|
+
server.scenario_manager.set_location_scenario(location, config)
|
|
792
|
+
|
|
793
|
+
# Invalidate cache for all devices
|
|
794
|
+
for device in server.get_all_devices():
|
|
795
|
+
device.invalidate_scenario_cache()
|
|
796
|
+
|
|
797
|
+
# Save to disk if persistence is enabled
|
|
798
|
+
if server.scenario_persistence:
|
|
799
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
800
|
+
|
|
801
|
+
return ScenarioResponse(
|
|
802
|
+
scope="location", identifier=location, scenario=scenario
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
@app.delete(
|
|
806
|
+
"/api/scenarios/locations/{location}",
|
|
807
|
+
status_code=204,
|
|
808
|
+
tags=["scenarios"],
|
|
809
|
+
summary="Clear location-specific scenario",
|
|
810
|
+
description="Clears the scenario for the specified location.",
|
|
811
|
+
responses={404: {"description": "Location scenario not found"}},
|
|
812
|
+
)
|
|
813
|
+
async def clear_location_scenario(location: str):
|
|
814
|
+
"""Clear location-specific scenario."""
|
|
815
|
+
if not server.scenario_manager.delete_location_scenario(location):
|
|
816
|
+
raise HTTPException(
|
|
817
|
+
status_code=404, detail=f"No scenario found for location {location}"
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
# Invalidate cache for all devices
|
|
821
|
+
for device in server.get_all_devices():
|
|
822
|
+
device.invalidate_scenario_cache()
|
|
823
|
+
|
|
824
|
+
# Save to disk if persistence is enabled
|
|
825
|
+
if server.scenario_persistence:
|
|
826
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
827
|
+
|
|
828
|
+
@app.get(
|
|
829
|
+
"/api/scenarios/groups/{group}",
|
|
830
|
+
response_model=ScenarioResponse,
|
|
831
|
+
tags=["scenarios"],
|
|
832
|
+
summary="Get group-specific scenario",
|
|
833
|
+
description="Returns the scenario for a specific group.",
|
|
834
|
+
responses={404: {"description": "Group scenario not found"}},
|
|
835
|
+
)
|
|
836
|
+
async def get_group_scenario(group: str):
|
|
837
|
+
"""Get group-specific scenario."""
|
|
838
|
+
config = server.scenario_manager.get_group_scenario(group)
|
|
839
|
+
if config is None:
|
|
840
|
+
raise HTTPException(
|
|
841
|
+
status_code=404, detail=f"No scenario found for group {group}"
|
|
842
|
+
)
|
|
843
|
+
return ScenarioResponse(
|
|
844
|
+
scope="group", identifier=group, scenario=_scenario_config_to_model(config)
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
@app.put(
|
|
848
|
+
"/api/scenarios/groups/{group}",
|
|
849
|
+
response_model=ScenarioResponse,
|
|
850
|
+
tags=["scenarios"],
|
|
851
|
+
summary="Set group-specific scenario",
|
|
852
|
+
description=(
|
|
853
|
+
"Sets a scenario that applies to all devices in a specific group."
|
|
854
|
+
),
|
|
855
|
+
)
|
|
856
|
+
async def set_group_scenario(group: str, scenario: ScenarioConfigModel):
|
|
857
|
+
"""Set group-specific scenario."""
|
|
858
|
+
config = _model_to_scenario_config(scenario)
|
|
859
|
+
server.scenario_manager.set_group_scenario(group, config)
|
|
860
|
+
|
|
861
|
+
# Invalidate cache for all devices
|
|
862
|
+
for device in server.get_all_devices():
|
|
863
|
+
device.invalidate_scenario_cache()
|
|
864
|
+
|
|
865
|
+
# Save to disk if persistence is enabled
|
|
866
|
+
if server.scenario_persistence:
|
|
867
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
868
|
+
|
|
869
|
+
return ScenarioResponse(scope="group", identifier=group, scenario=scenario)
|
|
870
|
+
|
|
871
|
+
@app.delete(
|
|
872
|
+
"/api/scenarios/groups/{group}",
|
|
873
|
+
status_code=204,
|
|
874
|
+
tags=["scenarios"],
|
|
875
|
+
summary="Clear group-specific scenario",
|
|
876
|
+
description="Clears the scenario for the specified group.",
|
|
877
|
+
responses={404: {"description": "Group scenario not found"}},
|
|
878
|
+
)
|
|
879
|
+
async def clear_group_scenario(group: str):
|
|
880
|
+
"""Clear group-specific scenario."""
|
|
881
|
+
if not server.scenario_manager.delete_group_scenario(group):
|
|
882
|
+
raise HTTPException(
|
|
883
|
+
status_code=404, detail=f"No scenario found for group {group}"
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# Invalidate cache for all devices
|
|
887
|
+
for device in server.get_all_devices():
|
|
888
|
+
device.invalidate_scenario_cache()
|
|
889
|
+
|
|
890
|
+
# Save to disk if persistence is enabled
|
|
891
|
+
if server.scenario_persistence:
|
|
892
|
+
server.scenario_persistence.save(server.scenario_manager)
|
|
893
|
+
|
|
894
|
+
return app
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
async def run_api_server(
|
|
898
|
+
server: EmulatedLifxServer, host: str = "127.0.0.1", port: int = 8080
|
|
899
|
+
):
|
|
900
|
+
"""Run the FastAPI server.
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
server: The LIFX emulator server instance
|
|
904
|
+
host: Host to bind to
|
|
905
|
+
port: Port to bind to
|
|
906
|
+
"""
|
|
907
|
+
import uvicorn
|
|
908
|
+
|
|
909
|
+
app = create_api_app(server)
|
|
910
|
+
|
|
911
|
+
config = uvicorn.Config(
|
|
912
|
+
app,
|
|
913
|
+
host=host,
|
|
914
|
+
port=port,
|
|
915
|
+
log_level="info",
|
|
916
|
+
access_log=True,
|
|
917
|
+
)
|
|
918
|
+
api_server = uvicorn.Server(config)
|
|
919
|
+
|
|
920
|
+
logger.info("Starting API server on http://%s:%s", host, port)
|
|
921
|
+
await api_server.serve()
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
# Embedded web UI
|
|
925
|
+
HTML_UI = """
|
|
926
|
+
<!DOCTYPE html>
|
|
927
|
+
<html lang="en">
|
|
928
|
+
<head>
|
|
929
|
+
<meta charset="UTF-8">
|
|
930
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
931
|
+
<title>LIFX Emulator Monitor</title>
|
|
932
|
+
<style>
|
|
933
|
+
* {
|
|
934
|
+
margin: 0;
|
|
935
|
+
padding: 0;
|
|
936
|
+
box-sizing: border-box;
|
|
937
|
+
}
|
|
938
|
+
body {
|
|
939
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
|
940
|
+
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
941
|
+
background: #0a0a0a;
|
|
942
|
+
color: #e0e0e0;
|
|
943
|
+
line-height: 1.6;
|
|
944
|
+
padding: 20px;
|
|
945
|
+
}
|
|
946
|
+
.container {
|
|
947
|
+
max-width: 1400px;
|
|
948
|
+
margin: 0 auto;
|
|
949
|
+
}
|
|
950
|
+
h1 {
|
|
951
|
+
color: #fff;
|
|
952
|
+
margin-bottom: 10px;
|
|
953
|
+
font-size: 2em;
|
|
954
|
+
}
|
|
955
|
+
.subtitle {
|
|
956
|
+
color: #888;
|
|
957
|
+
margin-bottom: 30px;
|
|
958
|
+
}
|
|
959
|
+
.grid {
|
|
960
|
+
display: grid;
|
|
961
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
962
|
+
gap: 15px;
|
|
963
|
+
margin-bottom: 25px;
|
|
964
|
+
}
|
|
965
|
+
.devices-grid {
|
|
966
|
+
display: grid;
|
|
967
|
+
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
968
|
+
gap: 10px;
|
|
969
|
+
}
|
|
970
|
+
.card {
|
|
971
|
+
background: #1a1a1a;
|
|
972
|
+
border: 1px solid #333;
|
|
973
|
+
border-radius: 8px;
|
|
974
|
+
padding: 20px;
|
|
975
|
+
}
|
|
976
|
+
.card h2 {
|
|
977
|
+
color: #fff;
|
|
978
|
+
font-size: 1.2em;
|
|
979
|
+
margin-bottom: 15px;
|
|
980
|
+
display: flex;
|
|
981
|
+
align-items: center;
|
|
982
|
+
gap: 10px;
|
|
983
|
+
}
|
|
984
|
+
.stat {
|
|
985
|
+
display: flex;
|
|
986
|
+
justify-content: space-between;
|
|
987
|
+
padding: 8px 0;
|
|
988
|
+
border-bottom: 1px solid #2a2a2a;
|
|
989
|
+
}
|
|
990
|
+
.stat:last-child {
|
|
991
|
+
border-bottom: none;
|
|
992
|
+
}
|
|
993
|
+
.stat-label {
|
|
994
|
+
color: #888;
|
|
995
|
+
}
|
|
996
|
+
.stat-value {
|
|
997
|
+
color: #fff;
|
|
998
|
+
font-weight: 600;
|
|
999
|
+
}
|
|
1000
|
+
.device {
|
|
1001
|
+
background: #252525;
|
|
1002
|
+
border: 1px solid #333;
|
|
1003
|
+
border-radius: 6px;
|
|
1004
|
+
padding: 8px;
|
|
1005
|
+
margin-bottom: 8px;
|
|
1006
|
+
font-size: 0.85em;
|
|
1007
|
+
}
|
|
1008
|
+
.device-header {
|
|
1009
|
+
display: flex;
|
|
1010
|
+
justify-content: space-between;
|
|
1011
|
+
align-items: center;
|
|
1012
|
+
margin-bottom: 6px;
|
|
1013
|
+
}
|
|
1014
|
+
.device-serial {
|
|
1015
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
1016
|
+
color: #4a9eff;
|
|
1017
|
+
font-weight: bold;
|
|
1018
|
+
font-size: 0.9em;
|
|
1019
|
+
}
|
|
1020
|
+
.device-label {
|
|
1021
|
+
color: #aaa;
|
|
1022
|
+
font-size: 0.85em;
|
|
1023
|
+
}
|
|
1024
|
+
.zones-container {
|
|
1025
|
+
margin-top: 8px;
|
|
1026
|
+
padding-top: 8px;
|
|
1027
|
+
border-top: 1px solid #333;
|
|
1028
|
+
}
|
|
1029
|
+
.zones-toggle, .metadata-toggle {
|
|
1030
|
+
cursor: pointer;
|
|
1031
|
+
color: #4a9eff;
|
|
1032
|
+
font-size: 0.8em;
|
|
1033
|
+
margin-top: 4px;
|
|
1034
|
+
user-select: none;
|
|
1035
|
+
}
|
|
1036
|
+
.zones-toggle:hover, .metadata-toggle:hover {
|
|
1037
|
+
color: #6bb0ff;
|
|
1038
|
+
}
|
|
1039
|
+
.zones-display, .metadata-display {
|
|
1040
|
+
display: none;
|
|
1041
|
+
margin-top: 6px;
|
|
1042
|
+
}
|
|
1043
|
+
.zones-display.show, .metadata-display.show {
|
|
1044
|
+
display: block;
|
|
1045
|
+
}
|
|
1046
|
+
.metadata-display {
|
|
1047
|
+
font-size: 0.75em;
|
|
1048
|
+
color: #888;
|
|
1049
|
+
padding: 6px;
|
|
1050
|
+
background: #1a1a1a;
|
|
1051
|
+
border-radius: 3px;
|
|
1052
|
+
border: 1px solid #333;
|
|
1053
|
+
}
|
|
1054
|
+
.metadata-row {
|
|
1055
|
+
display: flex;
|
|
1056
|
+
justify-content: space-between;
|
|
1057
|
+
padding: 2px 0;
|
|
1058
|
+
}
|
|
1059
|
+
.metadata-label {
|
|
1060
|
+
color: #666;
|
|
1061
|
+
}
|
|
1062
|
+
.metadata-value {
|
|
1063
|
+
color: #aaa;
|
|
1064
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
1065
|
+
}
|
|
1066
|
+
.zone-strip {
|
|
1067
|
+
display: flex;
|
|
1068
|
+
height: 20px;
|
|
1069
|
+
border-radius: 3px;
|
|
1070
|
+
overflow: hidden;
|
|
1071
|
+
margin-bottom: 4px;
|
|
1072
|
+
}
|
|
1073
|
+
.zone-segment {
|
|
1074
|
+
flex: 1;
|
|
1075
|
+
min-width: 4px;
|
|
1076
|
+
}
|
|
1077
|
+
.color-swatch {
|
|
1078
|
+
display: inline-block;
|
|
1079
|
+
width: 16px;
|
|
1080
|
+
height: 16px;
|
|
1081
|
+
border-radius: 3px;
|
|
1082
|
+
border: 1px solid #333;
|
|
1083
|
+
vertical-align: middle;
|
|
1084
|
+
margin-right: 4px;
|
|
1085
|
+
}
|
|
1086
|
+
.tile-grid {
|
|
1087
|
+
display: grid;
|
|
1088
|
+
gap: 2px;
|
|
1089
|
+
margin-top: 4px;
|
|
1090
|
+
}
|
|
1091
|
+
.tile-pixel {
|
|
1092
|
+
width: 8px;
|
|
1093
|
+
height: 8px;
|
|
1094
|
+
border-radius: 1px;
|
|
1095
|
+
}
|
|
1096
|
+
.tiles-container {
|
|
1097
|
+
display: flex;
|
|
1098
|
+
flex-wrap: wrap;
|
|
1099
|
+
gap: 8px;
|
|
1100
|
+
margin-top: 4px;
|
|
1101
|
+
}
|
|
1102
|
+
.tile-item {
|
|
1103
|
+
display: inline-block;
|
|
1104
|
+
}
|
|
1105
|
+
.badge {
|
|
1106
|
+
display: inline-block;
|
|
1107
|
+
padding: 2px 6px;
|
|
1108
|
+
border-radius: 3px;
|
|
1109
|
+
font-size: 0.7em;
|
|
1110
|
+
font-weight: 600;
|
|
1111
|
+
margin-right: 4px;
|
|
1112
|
+
margin-bottom: 2px;
|
|
1113
|
+
}
|
|
1114
|
+
.badge-power-on {
|
|
1115
|
+
background: #2d5;
|
|
1116
|
+
color: #000;
|
|
1117
|
+
}
|
|
1118
|
+
.badge-power-off {
|
|
1119
|
+
background: #555;
|
|
1120
|
+
color: #aaa;
|
|
1121
|
+
}
|
|
1122
|
+
.badge-capability {
|
|
1123
|
+
background: #333;
|
|
1124
|
+
color: #4a9eff;
|
|
1125
|
+
}
|
|
1126
|
+
.badge-extended-mz {
|
|
1127
|
+
background: #2d4a2d;
|
|
1128
|
+
color: #5dff5d;
|
|
1129
|
+
}
|
|
1130
|
+
.activity-log {
|
|
1131
|
+
background: #0d0d0d;
|
|
1132
|
+
border: 1px solid #333;
|
|
1133
|
+
border-radius: 6px;
|
|
1134
|
+
padding: 15px;
|
|
1135
|
+
max-height: 400px;
|
|
1136
|
+
overflow-y: auto;
|
|
1137
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
1138
|
+
font-size: 0.85em;
|
|
1139
|
+
}
|
|
1140
|
+
.activity-item {
|
|
1141
|
+
padding: 6px 0;
|
|
1142
|
+
border-bottom: 1px solid #1a1a1a;
|
|
1143
|
+
display: flex;
|
|
1144
|
+
gap: 10px;
|
|
1145
|
+
}
|
|
1146
|
+
.activity-item:last-child {
|
|
1147
|
+
border-bottom: none;
|
|
1148
|
+
}
|
|
1149
|
+
.activity-time {
|
|
1150
|
+
color: #666;
|
|
1151
|
+
min-width: 80px;
|
|
1152
|
+
}
|
|
1153
|
+
.activity-rx {
|
|
1154
|
+
color: #4a9eff;
|
|
1155
|
+
}
|
|
1156
|
+
.activity-tx {
|
|
1157
|
+
color: #f9a825;
|
|
1158
|
+
}
|
|
1159
|
+
.activity-packet {
|
|
1160
|
+
color: #aaa;
|
|
1161
|
+
}
|
|
1162
|
+
.btn {
|
|
1163
|
+
background: #4a9eff;
|
|
1164
|
+
color: #000;
|
|
1165
|
+
border: none;
|
|
1166
|
+
padding: 4px 8px;
|
|
1167
|
+
border-radius: 3px;
|
|
1168
|
+
cursor: pointer;
|
|
1169
|
+
font-weight: 600;
|
|
1170
|
+
font-size: 0.75em;
|
|
1171
|
+
}
|
|
1172
|
+
.btn:hover {
|
|
1173
|
+
background: #6bb0ff;
|
|
1174
|
+
}
|
|
1175
|
+
.btn-delete {
|
|
1176
|
+
background: #d32f2f;
|
|
1177
|
+
color: #fff;
|
|
1178
|
+
}
|
|
1179
|
+
.btn-delete:hover {
|
|
1180
|
+
background: #e57373;
|
|
1181
|
+
}
|
|
1182
|
+
.form-group {
|
|
1183
|
+
margin-bottom: 15px;
|
|
1184
|
+
}
|
|
1185
|
+
.form-group label {
|
|
1186
|
+
display: block;
|
|
1187
|
+
color: #aaa;
|
|
1188
|
+
margin-bottom: 5px;
|
|
1189
|
+
font-size: 0.9em;
|
|
1190
|
+
}
|
|
1191
|
+
.form-group input, .form-group select {
|
|
1192
|
+
width: 100%;
|
|
1193
|
+
background: #0d0d0d;
|
|
1194
|
+
border: 1px solid #333;
|
|
1195
|
+
color: #fff;
|
|
1196
|
+
padding: 8px;
|
|
1197
|
+
border-radius: 4px;
|
|
1198
|
+
}
|
|
1199
|
+
.status-indicator {
|
|
1200
|
+
display: inline-block;
|
|
1201
|
+
width: 8px;
|
|
1202
|
+
height: 8px;
|
|
1203
|
+
border-radius: 50%;
|
|
1204
|
+
background: #2d5;
|
|
1205
|
+
animation: pulse 2s infinite;
|
|
1206
|
+
}
|
|
1207
|
+
@keyframes pulse {
|
|
1208
|
+
0%, 100% { opacity: 1; }
|
|
1209
|
+
50% { opacity: 0.5; }
|
|
1210
|
+
}
|
|
1211
|
+
.no-devices {
|
|
1212
|
+
text-align: center;
|
|
1213
|
+
color: #666;
|
|
1214
|
+
padding: 40px;
|
|
1215
|
+
}
|
|
1216
|
+
</style>
|
|
1217
|
+
</head>
|
|
1218
|
+
<body>
|
|
1219
|
+
<div class="container">
|
|
1220
|
+
<h1>LIFX Emulator Monitor</h1>
|
|
1221
|
+
<p class="subtitle">Real-time monitoring and device management</p>
|
|
1222
|
+
|
|
1223
|
+
<div class="grid">
|
|
1224
|
+
<div class="card">
|
|
1225
|
+
<h2><span class="status-indicator"></span> Server Statistics</h2>
|
|
1226
|
+
<div id="stats">
|
|
1227
|
+
<div class="stat">
|
|
1228
|
+
<span class="stat-label">Loading...</span>
|
|
1229
|
+
<span class="stat-value"></span>
|
|
1230
|
+
</div>
|
|
1231
|
+
</div>
|
|
1232
|
+
</div>
|
|
1233
|
+
|
|
1234
|
+
<div class="card">
|
|
1235
|
+
<h2>Add Device</h2>
|
|
1236
|
+
<form id="add-device-form">
|
|
1237
|
+
<div class="form-group">
|
|
1238
|
+
<label>Product ID</label>
|
|
1239
|
+
<select id="product-id" required>
|
|
1240
|
+
<option value="27">27 - LIFX A19</option>
|
|
1241
|
+
<option value="29">29 - LIFX A19 Night Vision</option>
|
|
1242
|
+
<option value="32">32 - LIFX Z (Strip)</option>
|
|
1243
|
+
<option value="38">38 - LIFX Beam</option>
|
|
1244
|
+
<option value="50">50 - LIFX Mini White to Warm</option>
|
|
1245
|
+
<option value="55">55 - LIFX Tile</option>
|
|
1246
|
+
<option value="90">90 - LIFX Clean (HEV)</option>
|
|
1247
|
+
</select>
|
|
1248
|
+
</div>
|
|
1249
|
+
<button type="submit" class="btn">Add Device</button>
|
|
1250
|
+
</form>
|
|
1251
|
+
</div>
|
|
1252
|
+
</div>
|
|
1253
|
+
|
|
1254
|
+
<div class="card">
|
|
1255
|
+
<h2>
|
|
1256
|
+
Devices (<span id="device-count">0</span>)
|
|
1257
|
+
<span style="float: right; display: flex; gap: 8px;">
|
|
1258
|
+
<button
|
|
1259
|
+
class="btn btn-delete"
|
|
1260
|
+
onclick="removeAllDevices()"
|
|
1261
|
+
title="Remove all devices from server (runtime only)"
|
|
1262
|
+
>Remove All</button>
|
|
1263
|
+
<button
|
|
1264
|
+
class="btn btn-delete"
|
|
1265
|
+
onclick="clearStorage()"
|
|
1266
|
+
id="clear-storage-btn"
|
|
1267
|
+
title="Delete all persistent device state files"
|
|
1268
|
+
>Clear Storage</button>
|
|
1269
|
+
</span>
|
|
1270
|
+
</h2>
|
|
1271
|
+
<div id="devices" class="devices-grid"></div>
|
|
1272
|
+
</div>
|
|
1273
|
+
|
|
1274
|
+
<div class="card" id="activity-card">
|
|
1275
|
+
<h2>Recent Activity</h2>
|
|
1276
|
+
<div class="activity-log" id="activity-log"></div>
|
|
1277
|
+
</div>
|
|
1278
|
+
</div>
|
|
1279
|
+
|
|
1280
|
+
<script>
|
|
1281
|
+
let updateInterval;
|
|
1282
|
+
|
|
1283
|
+
// Convert HSBK to RGB for display
|
|
1284
|
+
function hsbkToRgb(hsbk) {
|
|
1285
|
+
const h = hsbk.hue / 65535;
|
|
1286
|
+
const s = hsbk.saturation / 65535;
|
|
1287
|
+
const v = hsbk.brightness / 65535;
|
|
1288
|
+
|
|
1289
|
+
let r, g, b;
|
|
1290
|
+
const i = Math.floor(h * 6);
|
|
1291
|
+
const f = h * 6 - i;
|
|
1292
|
+
const p = v * (1 - s);
|
|
1293
|
+
const q = v * (1 - f * s);
|
|
1294
|
+
const t = v * (1 - (1 - f) * s);
|
|
1295
|
+
|
|
1296
|
+
switch (i % 6) {
|
|
1297
|
+
case 0: r = v; g = t; b = p; break;
|
|
1298
|
+
case 1: r = q; g = v; b = p; break;
|
|
1299
|
+
case 2: r = p; g = v; b = t; break;
|
|
1300
|
+
case 3: r = p; g = q; b = v; break;
|
|
1301
|
+
case 4: r = t; g = p; b = v; break;
|
|
1302
|
+
case 5: r = v; g = p; b = q; break;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const red = Math.round(r * 255);
|
|
1306
|
+
const green = Math.round(g * 255);
|
|
1307
|
+
const blue = Math.round(b * 255);
|
|
1308
|
+
return `rgb(${red}, ${green}, ${blue})`;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function toggleZones(serial) {
|
|
1312
|
+
const element = document.getElementById(`zones-${serial}`);
|
|
1313
|
+
const toggle = document.getElementById(`zones-toggle-${serial}`);
|
|
1314
|
+
if (element && toggle) {
|
|
1315
|
+
const isShown = element.classList.toggle('show');
|
|
1316
|
+
// Update toggle icon
|
|
1317
|
+
toggle.textContent = isShown
|
|
1318
|
+
? toggle.textContent.replace('▸', '▾')
|
|
1319
|
+
: toggle.textContent.replace('▾', '▸');
|
|
1320
|
+
// Save state to localStorage
|
|
1321
|
+
localStorage.setItem(`zones-${serial}`, isShown ? 'show' : 'hide');
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function toggleMetadata(serial) {
|
|
1326
|
+
const element = document.getElementById(`metadata-${serial}`);
|
|
1327
|
+
const toggle = document.getElementById(`metadata-toggle-${serial}`);
|
|
1328
|
+
if (element && toggle) {
|
|
1329
|
+
const isShown = element.classList.toggle('show');
|
|
1330
|
+
// Update toggle icon
|
|
1331
|
+
toggle.textContent = isShown
|
|
1332
|
+
? toggle.textContent.replace('▸', '▾')
|
|
1333
|
+
: toggle.textContent.replace('▾', '▸');
|
|
1334
|
+
// Save state to localStorage
|
|
1335
|
+
localStorage.setItem(`metadata-${serial}`, isShown ? 'show' : 'hide');
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function restoreToggleStates(serial) {
|
|
1340
|
+
// Restore zones toggle state
|
|
1341
|
+
const zonesState = localStorage.getItem(`zones-${serial}`);
|
|
1342
|
+
if (zonesState === 'show') {
|
|
1343
|
+
const element = document.getElementById(`zones-${serial}`);
|
|
1344
|
+
const toggle = document.getElementById(`zones-toggle-${serial}`);
|
|
1345
|
+
if (element && toggle) {
|
|
1346
|
+
element.classList.add('show');
|
|
1347
|
+
toggle.textContent = toggle.textContent.replace('▸', '▾');
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Restore metadata toggle state
|
|
1352
|
+
const metadataState = localStorage.getItem(`metadata-${serial}`);
|
|
1353
|
+
if (metadataState === 'show') {
|
|
1354
|
+
const element = document.getElementById(`metadata-${serial}`);
|
|
1355
|
+
const toggle = document.getElementById(`metadata-toggle-${serial}`);
|
|
1356
|
+
if (element && toggle) {
|
|
1357
|
+
element.classList.add('show');
|
|
1358
|
+
toggle.textContent = toggle.textContent.replace('▸', '▾');
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
async function fetchStats() {
|
|
1364
|
+
try {
|
|
1365
|
+
const response = await fetch('/api/stats');
|
|
1366
|
+
if (!response.ok) {
|
|
1367
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1368
|
+
}
|
|
1369
|
+
const stats = await response.json();
|
|
1370
|
+
|
|
1371
|
+
const uptimeValue = Math.floor(stats.uptime_seconds);
|
|
1372
|
+
const statsHtml = `
|
|
1373
|
+
<div class="stat">
|
|
1374
|
+
<span class="stat-label">Uptime</span>
|
|
1375
|
+
<span class="stat-value">${uptimeValue}s</span>
|
|
1376
|
+
</div>
|
|
1377
|
+
<div class="stat">
|
|
1378
|
+
<span class="stat-label">Devices</span>
|
|
1379
|
+
<span class="stat-value">${stats.device_count}</span>
|
|
1380
|
+
</div>
|
|
1381
|
+
<div class="stat">
|
|
1382
|
+
<span class="stat-label">Packets RX</span>
|
|
1383
|
+
<span class="stat-value">${stats.packets_received}</span>
|
|
1384
|
+
</div>
|
|
1385
|
+
<div class="stat">
|
|
1386
|
+
<span class="stat-label">Packets TX</span>
|
|
1387
|
+
<span class="stat-value">${stats.packets_sent}</span>
|
|
1388
|
+
</div>
|
|
1389
|
+
<div class="stat">
|
|
1390
|
+
<span class="stat-label">Errors</span>
|
|
1391
|
+
<span class="stat-value">${stats.error_count}</span>
|
|
1392
|
+
</div>
|
|
1393
|
+
`;
|
|
1394
|
+
document.getElementById('stats').innerHTML = statsHtml;
|
|
1395
|
+
|
|
1396
|
+
// Show/hide activity log based on server configuration
|
|
1397
|
+
const activityCard = document.getElementById('activity-card');
|
|
1398
|
+
if (activityCard) {
|
|
1399
|
+
const displayValue = (
|
|
1400
|
+
stats.activity_enabled ? 'block' : 'none'
|
|
1401
|
+
);
|
|
1402
|
+
activityCard.style.display = displayValue;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return stats.activity_enabled;
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
console.error('Failed to fetch stats:', error);
|
|
1408
|
+
const errorLabelStyle = 'color: #d32f2f;';
|
|
1409
|
+
const errorHtml = `
|
|
1410
|
+
<div class="stat">
|
|
1411
|
+
<span class="stat-label" style="${errorLabelStyle}">
|
|
1412
|
+
Error loading stats
|
|
1413
|
+
</span>
|
|
1414
|
+
<span class="stat-value">${error.message}</span>
|
|
1415
|
+
</div>
|
|
1416
|
+
`;
|
|
1417
|
+
document.getElementById('stats').innerHTML = errorHtml;
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
async function fetchDevices() {
|
|
1423
|
+
try {
|
|
1424
|
+
const response = await fetch('/api/devices');
|
|
1425
|
+
if (!response.ok) {
|
|
1426
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1427
|
+
}
|
|
1428
|
+
const devices = await response.json();
|
|
1429
|
+
|
|
1430
|
+
document.getElementById('device-count').textContent = devices.length;
|
|
1431
|
+
|
|
1432
|
+
if (devices.length === 0) {
|
|
1433
|
+
const noDevicesHtml = (
|
|
1434
|
+
'<div class="no-devices">No devices emulated</div>'
|
|
1435
|
+
);
|
|
1436
|
+
document.getElementById('devices').innerHTML = noDevicesHtml;
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const devicesHtml = devices.map(dev => {
|
|
1441
|
+
const capabilities = [];
|
|
1442
|
+
const capabilityBadges = [];
|
|
1443
|
+
|
|
1444
|
+
if (dev.has_color) capabilities.push('color');
|
|
1445
|
+
if (dev.has_infrared) capabilities.push('IR');
|
|
1446
|
+
|
|
1447
|
+
// Show extended-mz badge instead of multizone when both are present
|
|
1448
|
+
if (dev.has_extended_multizone) {
|
|
1449
|
+
const badgeHtml = (
|
|
1450
|
+
'<span class="badge badge-extended-mz">' +
|
|
1451
|
+
`extended-mz×${dev.zone_count}</span>`
|
|
1452
|
+
);
|
|
1453
|
+
capabilityBadges.push(badgeHtml);
|
|
1454
|
+
} else if (dev.has_multizone) {
|
|
1455
|
+
capabilities.push(`multizone×${dev.zone_count}`);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
if (dev.has_matrix) capabilities.push(`matrix×${dev.tile_count}`);
|
|
1459
|
+
if (dev.has_hev) capabilities.push('HEV');
|
|
1460
|
+
|
|
1461
|
+
const powerBadge = dev.power_level > 0
|
|
1462
|
+
? '<span class="badge badge-power-on">ON</span>'
|
|
1463
|
+
: '<span class="badge badge-power-off">OFF</span>';
|
|
1464
|
+
|
|
1465
|
+
// Generate capabilities list for metadata
|
|
1466
|
+
const capabilitiesMetadata = [];
|
|
1467
|
+
if (dev.has_color) capabilitiesMetadata.push('Color');
|
|
1468
|
+
if (dev.has_infrared) {
|
|
1469
|
+
capabilitiesMetadata.push('Infrared');
|
|
1470
|
+
}
|
|
1471
|
+
if (dev.has_multizone) {
|
|
1472
|
+
capabilitiesMetadata.push(
|
|
1473
|
+
`Multizone (${dev.zone_count} zones)`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
if (dev.has_extended_multizone) {
|
|
1477
|
+
capabilitiesMetadata.push('Extended Multizone');
|
|
1478
|
+
}
|
|
1479
|
+
if (dev.has_matrix) {
|
|
1480
|
+
capabilitiesMetadata.push(
|
|
1481
|
+
`Matrix (${dev.tile_count} tiles)`
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
if (dev.has_hev) capabilitiesMetadata.push('HEV/Clean');
|
|
1485
|
+
const capabilitiesText = (
|
|
1486
|
+
capabilitiesMetadata.join(', ') || 'None'
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
// Generate metadata display
|
|
1490
|
+
const uptimeSeconds = Math.floor(dev.uptime_ns / 1e9);
|
|
1491
|
+
const metaToggleId = `metadata-toggle-${dev.serial}`;
|
|
1492
|
+
const metaToggleClick = `toggleMetadata('${dev.serial}')`;
|
|
1493
|
+
const metadataHtml = `
|
|
1494
|
+
<div
|
|
1495
|
+
class="metadata-toggle"
|
|
1496
|
+
id="${metaToggleId}"
|
|
1497
|
+
onclick="${metaToggleClick}"
|
|
1498
|
+
>
|
|
1499
|
+
▸ Show metadata
|
|
1500
|
+
</div>
|
|
1501
|
+
<div id="metadata-${dev.serial}" class="metadata-display">
|
|
1502
|
+
<div class="metadata-row">
|
|
1503
|
+
<span class="metadata-label">Firmware:</span>
|
|
1504
|
+
<span class="metadata-value">
|
|
1505
|
+
${dev.version_major}.${dev.version_minor}
|
|
1506
|
+
</span>
|
|
1507
|
+
</div>
|
|
1508
|
+
<div class="metadata-row">
|
|
1509
|
+
<span class="metadata-label">Vendor:</span>
|
|
1510
|
+
<span class="metadata-value">${dev.vendor}</span>
|
|
1511
|
+
</div>
|
|
1512
|
+
<div class="metadata-row">
|
|
1513
|
+
<span class="metadata-label">Product:</span>
|
|
1514
|
+
<span class="metadata-value">${dev.product}</span>
|
|
1515
|
+
</div>
|
|
1516
|
+
<div class="metadata-row">
|
|
1517
|
+
<span class="metadata-label">Capabilities:</span>
|
|
1518
|
+
<span
|
|
1519
|
+
class="metadata-value"
|
|
1520
|
+
style="color: #4a9eff;"
|
|
1521
|
+
>${capabilitiesText}</span>
|
|
1522
|
+
</div>
|
|
1523
|
+
<div class="metadata-row">
|
|
1524
|
+
<span class="metadata-label">Group:</span>
|
|
1525
|
+
<span class="metadata-value">${dev.group_label}</span>
|
|
1526
|
+
</div>
|
|
1527
|
+
<div class="metadata-row">
|
|
1528
|
+
<span class="metadata-label">Location:</span>
|
|
1529
|
+
<span class="metadata-value">${dev.location_label}</span>
|
|
1530
|
+
</div>
|
|
1531
|
+
<div class="metadata-row">
|
|
1532
|
+
<span class="metadata-label">Uptime:</span>
|
|
1533
|
+
<span class="metadata-value">${uptimeSeconds}s</span>
|
|
1534
|
+
</div>
|
|
1535
|
+
<div class="metadata-row">
|
|
1536
|
+
<span class="metadata-label">WiFi Signal:</span>
|
|
1537
|
+
<span class="metadata-value">
|
|
1538
|
+
${dev.wifi_signal.toFixed(1)} dBm
|
|
1539
|
+
</span>
|
|
1540
|
+
</div>
|
|
1541
|
+
</div>
|
|
1542
|
+
`;
|
|
1543
|
+
|
|
1544
|
+
// Generate zones display
|
|
1545
|
+
let zonesHtml = '';
|
|
1546
|
+
if (dev.has_multizone && dev.zone_colors &&
|
|
1547
|
+
dev.zone_colors.length > 0
|
|
1548
|
+
) {
|
|
1549
|
+
const zoneSegments = dev.zone_colors.map(color => {
|
|
1550
|
+
const rgb = hsbkToRgb(color);
|
|
1551
|
+
const bgStyle = `background: ${rgb};`;
|
|
1552
|
+
return `<div class="zone-segment" style="${bgStyle}"></div>`;
|
|
1553
|
+
}).join('');
|
|
1554
|
+
|
|
1555
|
+
const zoneCount = dev.zone_colors.length;
|
|
1556
|
+
const toggleId = `zones-toggle-${dev.serial}`;
|
|
1557
|
+
const toggleClick = `toggleZones('${dev.serial}')`;
|
|
1558
|
+
zonesHtml = `
|
|
1559
|
+
<div
|
|
1560
|
+
class="zones-toggle"
|
|
1561
|
+
id="${toggleId}"
|
|
1562
|
+
onclick="${toggleClick}"
|
|
1563
|
+
>
|
|
1564
|
+
▸ Show zones (${zoneCount})
|
|
1565
|
+
</div>
|
|
1566
|
+
<div id="zones-${dev.serial}" class="zones-display">
|
|
1567
|
+
<div class="zone-strip">${zoneSegments}</div>
|
|
1568
|
+
</div>
|
|
1569
|
+
`;
|
|
1570
|
+
} else if (dev.has_matrix && dev.tile_devices &&
|
|
1571
|
+
dev.tile_devices.length > 0) {
|
|
1572
|
+
// Render actual tile pixels
|
|
1573
|
+
const tilesHtml = dev.tile_devices.map((tile, tileIndex) => {
|
|
1574
|
+
if (!tile.colors || tile.colors.length === 0) {
|
|
1575
|
+
return '<div style="color: #666;">No color data</div>';
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const width = tile.width || 8;
|
|
1579
|
+
const height = tile.height || 8;
|
|
1580
|
+
const totalPixels = width * height;
|
|
1581
|
+
|
|
1582
|
+
// Create grid of pixels
|
|
1583
|
+
const slicedColors = tile.colors.slice(0, totalPixels);
|
|
1584
|
+
const pixelsHtml = slicedColors.map(color => {
|
|
1585
|
+
const rgb = hsbkToRgb(color);
|
|
1586
|
+
const bgStyle = `background: ${rgb};`;
|
|
1587
|
+
return `<div class="tile-pixel" style="${bgStyle}"></div>`;
|
|
1588
|
+
}).join('');
|
|
1589
|
+
|
|
1590
|
+
const labelStyle = (
|
|
1591
|
+
'font-size: 0.7em; color: #666; ' +
|
|
1592
|
+
'margin-bottom: 2px; text-align: center;'
|
|
1593
|
+
);
|
|
1594
|
+
const gridStyle = (
|
|
1595
|
+
`grid-template-columns: repeat(${width}, 8px);`
|
|
1596
|
+
);
|
|
1597
|
+
return `
|
|
1598
|
+
<div class="tile-item">
|
|
1599
|
+
<div style="${labelStyle}">
|
|
1600
|
+
T${tileIndex + 1}
|
|
1601
|
+
</div>
|
|
1602
|
+
<div class="tile-grid" style="${gridStyle}">
|
|
1603
|
+
${pixelsHtml}
|
|
1604
|
+
</div>
|
|
1605
|
+
</div>
|
|
1606
|
+
`;
|
|
1607
|
+
}).join('');
|
|
1608
|
+
|
|
1609
|
+
const tileCount = dev.tile_devices.length;
|
|
1610
|
+
const toggleId = `zones-toggle-${dev.serial}`;
|
|
1611
|
+
const toggleClick = `toggleZones('${dev.serial}')`;
|
|
1612
|
+
zonesHtml = `
|
|
1613
|
+
<div
|
|
1614
|
+
class="zones-toggle"
|
|
1615
|
+
id="${toggleId}"
|
|
1616
|
+
onclick="${toggleClick}"
|
|
1617
|
+
>
|
|
1618
|
+
▸ Show tiles (${tileCount})
|
|
1619
|
+
</div>
|
|
1620
|
+
<div id="zones-${dev.serial}" class="zones-display">
|
|
1621
|
+
<div class="tiles-container">
|
|
1622
|
+
${tilesHtml}
|
|
1623
|
+
</div>
|
|
1624
|
+
</div>
|
|
1625
|
+
`;
|
|
1626
|
+
} else if (dev.has_color && dev.color) {
|
|
1627
|
+
const rgb = hsbkToRgb(dev.color);
|
|
1628
|
+
const swatchStyle = `background: ${rgb};`;
|
|
1629
|
+
const textStyle = 'color: #888; font-size: 0.75em;';
|
|
1630
|
+
zonesHtml = `
|
|
1631
|
+
<div style="margin-top: 4px;">
|
|
1632
|
+
<span class="color-swatch" style="${swatchStyle}"></span>
|
|
1633
|
+
<span style="${textStyle}">Current color</span>
|
|
1634
|
+
</div>
|
|
1635
|
+
`;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
return `
|
|
1639
|
+
<div class="device">
|
|
1640
|
+
<div class="device-header">
|
|
1641
|
+
<div>
|
|
1642
|
+
<div class="device-serial">${dev.serial}</div>
|
|
1643
|
+
<div class="device-label">${dev.label}</div>
|
|
1644
|
+
</div>
|
|
1645
|
+
<button
|
|
1646
|
+
class="btn btn-delete"
|
|
1647
|
+
onclick="deleteDevice('${dev.serial}')"
|
|
1648
|
+
>Del</button>
|
|
1649
|
+
</div>
|
|
1650
|
+
<div>
|
|
1651
|
+
${powerBadge}
|
|
1652
|
+
<span class="badge badge-capability">P${dev.product}</span>
|
|
1653
|
+
${capabilities.map(c => (
|
|
1654
|
+
`<span class="badge badge-capability">${c}</span>`
|
|
1655
|
+
)).join('')}
|
|
1656
|
+
${capabilityBadges.join('')}
|
|
1657
|
+
</div>
|
|
1658
|
+
${metadataHtml}
|
|
1659
|
+
${zonesHtml}
|
|
1660
|
+
</div>
|
|
1661
|
+
`;
|
|
1662
|
+
}).join('');
|
|
1663
|
+
|
|
1664
|
+
document.getElementById('devices').innerHTML = devicesHtml;
|
|
1665
|
+
|
|
1666
|
+
// Restore toggle states for all devices
|
|
1667
|
+
devices.forEach(dev => restoreToggleStates(dev.serial));
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
console.error('Failed to fetch devices:', error);
|
|
1670
|
+
const errorStyle = 'color: #d32f2f;';
|
|
1671
|
+
const errorHtml = (
|
|
1672
|
+
`<div class="no-devices" style="${errorStyle}">` +
|
|
1673
|
+
`Error loading devices: ${error.message}</div>`
|
|
1674
|
+
);
|
|
1675
|
+
document.getElementById('devices').innerHTML = errorHtml;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
async function fetchActivity() {
|
|
1680
|
+
try {
|
|
1681
|
+
const response = await fetch('/api/activity');
|
|
1682
|
+
if (!response.ok) {
|
|
1683
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1684
|
+
}
|
|
1685
|
+
const activities = await response.json();
|
|
1686
|
+
|
|
1687
|
+
const activityHtml = activities.slice().reverse().map(act => {
|
|
1688
|
+
const timestamp = act.timestamp * 1000;
|
|
1689
|
+
const time = new Date(timestamp).toLocaleTimeString();
|
|
1690
|
+
const isRx = act.direction === 'rx';
|
|
1691
|
+
const dirClass = isRx ? 'activity-rx' : 'activity-tx';
|
|
1692
|
+
const dirLabel = isRx ? 'RX' : 'TX';
|
|
1693
|
+
const device = act.device || act.target || 'N/A';
|
|
1694
|
+
|
|
1695
|
+
return `
|
|
1696
|
+
<div class="activity-item">
|
|
1697
|
+
<span class="activity-time">${time}</span>
|
|
1698
|
+
<span class="${dirClass}">${dirLabel}</span>
|
|
1699
|
+
<span class="activity-packet">${act.packet_name}</span>
|
|
1700
|
+
<span class="device-serial">${device}</span>
|
|
1701
|
+
<span style="color: #666">${act.addr}</span>
|
|
1702
|
+
</div>
|
|
1703
|
+
`;
|
|
1704
|
+
}).join('');
|
|
1705
|
+
|
|
1706
|
+
const noActivity = '<div style="color: #666">No activity yet</div>';
|
|
1707
|
+
const logElement = document.getElementById('activity-log');
|
|
1708
|
+
logElement.innerHTML = activityHtml || noActivity;
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
console.error('Failed to fetch activity:', error);
|
|
1711
|
+
const errorStyle = 'color: #d32f2f;';
|
|
1712
|
+
const errorHtml = (
|
|
1713
|
+
`<div style="${errorStyle}">` +
|
|
1714
|
+
`Error loading activity: ${error.message}</div>`
|
|
1715
|
+
);
|
|
1716
|
+
document.getElementById('activity-log').innerHTML = errorHtml;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
async function deleteDevice(serial) {
|
|
1721
|
+
if (!confirm(`Delete device ${serial}?`)) return;
|
|
1722
|
+
|
|
1723
|
+
const response = await fetch(`/api/devices/${serial}`, {
|
|
1724
|
+
method: 'DELETE'
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
if (response.ok) {
|
|
1728
|
+
await updateAll();
|
|
1729
|
+
} else {
|
|
1730
|
+
alert('Failed to delete device');
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
async function removeAllDevices() {
|
|
1735
|
+
const deviceCount = document.getElementById('device-count').textContent;
|
|
1736
|
+
if (deviceCount === '0') {
|
|
1737
|
+
alert('No devices to remove');
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const line1 = (
|
|
1742
|
+
`Remove all ${deviceCount} device(s) from the server?\\n\\n`
|
|
1743
|
+
);
|
|
1744
|
+
const line2 = (
|
|
1745
|
+
'This will stop all devices from ' +
|
|
1746
|
+
'responding to LIFX protocol packets, '
|
|
1747
|
+
);
|
|
1748
|
+
const line3 = 'but will not delete persistent storage.';
|
|
1749
|
+
const confirmMsg = line1 + line2 + line3;
|
|
1750
|
+
if (!confirm(confirmMsg)) return;
|
|
1751
|
+
|
|
1752
|
+
const response = await fetch('/api/devices', {
|
|
1753
|
+
method: 'DELETE'
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
if (response.ok) {
|
|
1757
|
+
const result = await response.json();
|
|
1758
|
+
alert(result.message);
|
|
1759
|
+
await updateAll();
|
|
1760
|
+
} else {
|
|
1761
|
+
alert('Failed to remove all devices');
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
async function clearStorage() {
|
|
1766
|
+
const confirmMsg = `Clear all persistent device state from storage?\\n\\n` +
|
|
1767
|
+
`This will permanently delete all saved device state files. ` +
|
|
1768
|
+
`Currently running devices will not be affected.\\n\\n` +
|
|
1769
|
+
`This action cannot be undone.`;
|
|
1770
|
+
if (!confirm(confirmMsg)) return;
|
|
1771
|
+
|
|
1772
|
+
const response = await fetch('/api/storage', {
|
|
1773
|
+
method: 'DELETE'
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
if (response.ok) {
|
|
1777
|
+
const result = await response.json();
|
|
1778
|
+
alert(result.message);
|
|
1779
|
+
} else if (response.status === 503) {
|
|
1780
|
+
alert('Persistent storage is not enabled on this server');
|
|
1781
|
+
} else {
|
|
1782
|
+
alert('Failed to clear storage');
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const addDeviceForm = document.getElementById('add-device-form');
|
|
1787
|
+
addDeviceForm.addEventListener('submit', async (e) => {
|
|
1788
|
+
e.preventDefault();
|
|
1789
|
+
|
|
1790
|
+
const productId = parseInt(document.getElementById('product-id').value);
|
|
1791
|
+
|
|
1792
|
+
const response = await fetch('/api/devices', {
|
|
1793
|
+
method: 'POST',
|
|
1794
|
+
headers: {
|
|
1795
|
+
'Content-Type': 'application/json'
|
|
1796
|
+
},
|
|
1797
|
+
body: JSON.stringify({ product_id: productId })
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
if (response.ok) {
|
|
1801
|
+
await updateAll();
|
|
1802
|
+
} else {
|
|
1803
|
+
const error = await response.json();
|
|
1804
|
+
alert(`Failed to create device: ${error.detail}`);
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
async function updateAll() {
|
|
1809
|
+
const activityEnabled = await fetchStats();
|
|
1810
|
+
const tasks = [fetchDevices()];
|
|
1811
|
+
if (activityEnabled) {
|
|
1812
|
+
tasks.push(fetchActivity());
|
|
1813
|
+
}
|
|
1814
|
+
await Promise.all(tasks);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// Initial load
|
|
1818
|
+
updateAll();
|
|
1819
|
+
|
|
1820
|
+
// Auto-refresh every 2 seconds
|
|
1821
|
+
updateInterval = setInterval(updateAll, 2000);
|
|
1822
|
+
</script>
|
|
1823
|
+
</body>
|
|
1824
|
+
</html>
|
|
1825
|
+
"""
|