lifx-emulator 1.0.2__py3-none-any.whl → 2.1.0__py3-none-any.whl

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