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.
Files changed (40) hide show
  1. lifx_emulator/__init__.py +31 -0
  2. lifx_emulator/__main__.py +607 -0
  3. lifx_emulator/api.py +1825 -0
  4. lifx_emulator/async_storage.py +308 -0
  5. lifx_emulator/constants.py +33 -0
  6. lifx_emulator/device.py +750 -0
  7. lifx_emulator/device_states.py +114 -0
  8. lifx_emulator/factories.py +380 -0
  9. lifx_emulator/handlers/__init__.py +39 -0
  10. lifx_emulator/handlers/base.py +49 -0
  11. lifx_emulator/handlers/device_handlers.py +340 -0
  12. lifx_emulator/handlers/light_handlers.py +372 -0
  13. lifx_emulator/handlers/multizone_handlers.py +249 -0
  14. lifx_emulator/handlers/registry.py +110 -0
  15. lifx_emulator/handlers/tile_handlers.py +309 -0
  16. lifx_emulator/observers.py +139 -0
  17. lifx_emulator/products/__init__.py +28 -0
  18. lifx_emulator/products/generator.py +771 -0
  19. lifx_emulator/products/registry.py +1446 -0
  20. lifx_emulator/products/specs.py +242 -0
  21. lifx_emulator/products/specs.yml +327 -0
  22. lifx_emulator/protocol/__init__.py +1 -0
  23. lifx_emulator/protocol/base.py +334 -0
  24. lifx_emulator/protocol/const.py +8 -0
  25. lifx_emulator/protocol/generator.py +1371 -0
  26. lifx_emulator/protocol/header.py +159 -0
  27. lifx_emulator/protocol/packets.py +1351 -0
  28. lifx_emulator/protocol/protocol_types.py +844 -0
  29. lifx_emulator/protocol/serializer.py +379 -0
  30. lifx_emulator/scenario_manager.py +402 -0
  31. lifx_emulator/scenario_persistence.py +206 -0
  32. lifx_emulator/server.py +482 -0
  33. lifx_emulator/state_restorer.py +259 -0
  34. lifx_emulator/state_serializer.py +130 -0
  35. lifx_emulator/storage_protocol.py +100 -0
  36. lifx_emulator-1.0.0.dist-info/METADATA +445 -0
  37. lifx_emulator-1.0.0.dist-info/RECORD +40 -0
  38. lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
  39. lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
  40. 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
+ """