lifx-emulator 1.0.2__py3-none-any.whl → 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lifx_emulator/__init__.py +1 -1
- lifx_emulator/__main__.py +26 -51
- lifx_emulator/api/__init__.py +18 -0
- lifx_emulator/api/app.py +154 -0
- lifx_emulator/api/mappers/__init__.py +5 -0
- lifx_emulator/api/mappers/device_mapper.py +114 -0
- lifx_emulator/api/models.py +133 -0
- lifx_emulator/api/routers/__init__.py +11 -0
- lifx_emulator/api/routers/devices.py +130 -0
- lifx_emulator/api/routers/monitoring.py +52 -0
- lifx_emulator/api/routers/scenarios.py +247 -0
- lifx_emulator/api/services/__init__.py +8 -0
- lifx_emulator/api/services/device_service.py +198 -0
- lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
- lifx_emulator/devices/__init__.py +37 -0
- lifx_emulator/devices/device.py +333 -0
- lifx_emulator/devices/manager.py +256 -0
- lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
- lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
- lifx_emulator/devices/states.py +346 -0
- lifx_emulator/factories/__init__.py +37 -0
- lifx_emulator/factories/builder.py +371 -0
- lifx_emulator/factories/default_config.py +158 -0
- lifx_emulator/factories/factory.py +221 -0
- lifx_emulator/factories/firmware_config.py +59 -0
- lifx_emulator/factories/serial_generator.py +82 -0
- lifx_emulator/handlers/base.py +1 -1
- lifx_emulator/handlers/device_handlers.py +10 -28
- lifx_emulator/handlers/light_handlers.py +5 -9
- lifx_emulator/handlers/multizone_handlers.py +1 -1
- lifx_emulator/handlers/tile_handlers.py +31 -11
- lifx_emulator/products/generator.py +389 -170
- lifx_emulator/products/registry.py +52 -40
- lifx_emulator/products/specs.py +12 -13
- lifx_emulator/protocol/base.py +175 -63
- lifx_emulator/protocol/generator.py +18 -5
- lifx_emulator/protocol/packets.py +7 -7
- lifx_emulator/protocol/protocol_types.py +35 -62
- lifx_emulator/repositories/__init__.py +22 -0
- lifx_emulator/repositories/device_repository.py +155 -0
- lifx_emulator/repositories/storage_backend.py +107 -0
- lifx_emulator/scenarios/__init__.py +22 -0
- lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
- lifx_emulator/scenarios/models.py +112 -0
- lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
- lifx_emulator/server.py +42 -66
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/METADATA +1 -1
- lifx_emulator-2.1.0.dist-info/RECORD +62 -0
- lifx_emulator/device.py +0 -750
- lifx_emulator/device_states.py +0 -114
- lifx_emulator/factories.py +0 -380
- lifx_emulator/storage_protocol.py +0 -100
- lifx_emulator-1.0.2.dist-info/RECORD +0 -40
- /lifx_emulator/{observers.py → devices/observers.py} +0 -0
- /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/entry_points.txt +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/licenses/LICENSE +0 -0
lifx_emulator/__init__.py
CHANGED
|
@@ -6,7 +6,7 @@ Implements the binary UDP protocol documented at https://lan.developer.lifx.com
|
|
|
6
6
|
|
|
7
7
|
from importlib.metadata import version as get_version
|
|
8
8
|
|
|
9
|
-
from lifx_emulator.
|
|
9
|
+
from lifx_emulator.devices import EmulatedLifxDevice
|
|
10
10
|
from lifx_emulator.factories import (
|
|
11
11
|
create_color_light,
|
|
12
12
|
create_color_temperature_light,
|
lifx_emulator/__main__.py
CHANGED
|
@@ -8,8 +8,12 @@ from typing import Annotated
|
|
|
8
8
|
import cyclopts
|
|
9
9
|
from rich.logging import RichHandler
|
|
10
10
|
|
|
11
|
-
from lifx_emulator.async_storage import AsyncDeviceStorage
|
|
12
11
|
from lifx_emulator.constants import LIFX_UDP_PORT
|
|
12
|
+
from lifx_emulator.devices import (
|
|
13
|
+
DEFAULT_STORAGE_DIR,
|
|
14
|
+
DeviceManager,
|
|
15
|
+
DevicePersistenceAsyncFile,
|
|
16
|
+
)
|
|
13
17
|
from lifx_emulator.factories import (
|
|
14
18
|
create_color_light,
|
|
15
19
|
create_color_temperature_light,
|
|
@@ -19,7 +23,9 @@ from lifx_emulator.factories import (
|
|
|
19
23
|
create_multizone_light,
|
|
20
24
|
create_tile_device,
|
|
21
25
|
)
|
|
22
|
-
from lifx_emulator.products.registry import
|
|
26
|
+
from lifx_emulator.products.registry import get_registry
|
|
27
|
+
from lifx_emulator.repositories import DeviceRepository
|
|
28
|
+
from lifx_emulator.scenarios import ScenarioPersistenceAsyncFile
|
|
23
29
|
from lifx_emulator.server import EmulatedLifxServer
|
|
24
30
|
|
|
25
31
|
app = cyclopts.App(
|
|
@@ -78,49 +84,6 @@ def _format_capabilities(device) -> str:
|
|
|
78
84
|
return ", ".join(capabilities)
|
|
79
85
|
|
|
80
86
|
|
|
81
|
-
def _format_product_capabilities(product: ProductInfo) -> str:
|
|
82
|
-
"""Format product capabilities as a human-readable string."""
|
|
83
|
-
caps = []
|
|
84
|
-
|
|
85
|
-
# Determine base light type
|
|
86
|
-
if product.has_relays:
|
|
87
|
-
# Devices with relays are switches, not lights
|
|
88
|
-
caps.append("switch")
|
|
89
|
-
elif product.has_color:
|
|
90
|
-
caps.append("full color")
|
|
91
|
-
else:
|
|
92
|
-
# Check temperature range to determine white light type
|
|
93
|
-
if product.temperature_range:
|
|
94
|
-
if product.temperature_range.min != product.temperature_range.max:
|
|
95
|
-
caps.append("color temperature")
|
|
96
|
-
else:
|
|
97
|
-
caps.append("brightness only")
|
|
98
|
-
else:
|
|
99
|
-
# No temperature range info, assume basic brightness
|
|
100
|
-
caps.append("brightness only")
|
|
101
|
-
|
|
102
|
-
# Add additional capabilities
|
|
103
|
-
if product.has_infrared:
|
|
104
|
-
caps.append("infrared")
|
|
105
|
-
# Extended multizone is backwards compatible with multizone,
|
|
106
|
-
# so only show multizone if extended multizone is not present
|
|
107
|
-
if product.has_extended_multizone:
|
|
108
|
-
caps.append("extended-multizone")
|
|
109
|
-
elif product.has_multizone:
|
|
110
|
-
caps.append("multizone")
|
|
111
|
-
if product.has_matrix:
|
|
112
|
-
caps.append("matrix")
|
|
113
|
-
if product.has_hev:
|
|
114
|
-
caps.append("HEV")
|
|
115
|
-
if product.has_chain:
|
|
116
|
-
caps.append("chain")
|
|
117
|
-
if product.has_buttons and not product.has_relays:
|
|
118
|
-
# Only show buttons if not already identified as switch
|
|
119
|
-
caps.append("buttons")
|
|
120
|
-
|
|
121
|
-
return ", ".join(caps) if caps else "unknown"
|
|
122
|
-
|
|
123
|
-
|
|
124
87
|
@app.command
|
|
125
88
|
def list_products(
|
|
126
89
|
filter_type: str | None = None,
|
|
@@ -177,8 +140,7 @@ def list_products(
|
|
|
177
140
|
print("─" * 4 + "─┼─" + "─" * 40 + "─┼─" + "─" * 40)
|
|
178
141
|
|
|
179
142
|
for product in all_products:
|
|
180
|
-
|
|
181
|
-
print(f"{product.pid:>4} │ {product.name:<40} │ {caps}")
|
|
143
|
+
print(f"{product.pid:>4} │ {product.name:<40} │ {product.caps}")
|
|
182
144
|
|
|
183
145
|
print()
|
|
184
146
|
print("Use --product <PID> to emulate a specific product")
|
|
@@ -213,13 +175,11 @@ def clear_storage(
|
|
|
213
175
|
"""
|
|
214
176
|
from pathlib import Path
|
|
215
177
|
|
|
216
|
-
from lifx_emulator.async_storage import DEFAULT_STORAGE_DIR, AsyncDeviceStorage
|
|
217
|
-
|
|
218
178
|
# Use default storage directory if not specified
|
|
219
179
|
storage_path = Path(storage_dir) if storage_dir else DEFAULT_STORAGE_DIR
|
|
220
180
|
|
|
221
181
|
# Create storage instance
|
|
222
|
-
storage =
|
|
182
|
+
storage = DevicePersistenceAsyncFile(storage_path)
|
|
223
183
|
|
|
224
184
|
# List devices
|
|
225
185
|
devices = storage.list_devices()
|
|
@@ -369,7 +329,7 @@ async def run(
|
|
|
369
329
|
return False
|
|
370
330
|
|
|
371
331
|
# Initialize storage if persistence is enabled
|
|
372
|
-
storage =
|
|
332
|
+
storage = DevicePersistenceAsyncFile() if persistent else None
|
|
373
333
|
if persistent and storage:
|
|
374
334
|
logger.info("Persistent storage enabled at %s", storage.storage_dir)
|
|
375
335
|
|
|
@@ -531,14 +491,29 @@ async def run(
|
|
|
531
491
|
caps = _format_capabilities(device)
|
|
532
492
|
logger.info(" • %s (%s) - %s", label, serial, caps)
|
|
533
493
|
|
|
494
|
+
# Create device manager with repository
|
|
495
|
+
device_repository = DeviceRepository()
|
|
496
|
+
device_manager = DeviceManager(device_repository)
|
|
497
|
+
|
|
498
|
+
# Load scenarios from storage if persistence is enabled
|
|
499
|
+
scenario_manager = None
|
|
500
|
+
scenario_storage = None
|
|
501
|
+
if persistent_scenarios:
|
|
502
|
+
scenario_storage = ScenarioPersistenceAsyncFile()
|
|
503
|
+
scenario_manager = await scenario_storage.load()
|
|
504
|
+
logger.info("Loaded scenarios from persistent storage")
|
|
505
|
+
|
|
534
506
|
# Start LIFX server
|
|
535
507
|
server = EmulatedLifxServer(
|
|
536
508
|
devices,
|
|
509
|
+
device_manager,
|
|
537
510
|
bind,
|
|
538
511
|
port,
|
|
539
512
|
track_activity=api_activity if api else False,
|
|
540
513
|
storage=storage,
|
|
514
|
+
scenario_manager=scenario_manager,
|
|
541
515
|
persist_scenarios=persistent_scenarios,
|
|
516
|
+
scenario_storage=scenario_storage,
|
|
542
517
|
)
|
|
543
518
|
await server.start()
|
|
544
519
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""FastAPI-based management API for LIFX emulator.
|
|
2
|
+
|
|
3
|
+
This package provides a comprehensive REST API for managing the LIFX emulator:
|
|
4
|
+
- Monitoring server statistics and activity
|
|
5
|
+
- Creating, listing, and deleting devices
|
|
6
|
+
- Managing test scenarios for protocol testing
|
|
7
|
+
|
|
8
|
+
The API is built with FastAPI and organized into routers for clean separation
|
|
9
|
+
of concerns.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Import from new refactored structure
|
|
13
|
+
from lifx_emulator.api.app import create_api_app, run_api_server
|
|
14
|
+
|
|
15
|
+
# Note: HTML_UI remains in the old lifx_emulator/api.py file temporarily
|
|
16
|
+
# TODO: Phase 1.1d - extract HTML template to separate file
|
|
17
|
+
|
|
18
|
+
__all__ = ["create_api_app", "run_api_server"]
|
lifx_emulator/api/app.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""FastAPI application factory for LIFX emulator management API.
|
|
2
|
+
|
|
3
|
+
This module creates the main FastAPI application by assembling routers for:
|
|
4
|
+
- Monitoring (server stats, activity)
|
|
5
|
+
- Devices (CRUD operations)
|
|
6
|
+
- Scenarios (test scenario management)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, Request
|
|
16
|
+
from fastapi.responses import HTMLResponse
|
|
17
|
+
from fastapi.templating import Jinja2Templates
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from lifx_emulator.server import EmulatedLifxServer
|
|
21
|
+
|
|
22
|
+
from lifx_emulator.api.routers.devices import create_devices_router
|
|
23
|
+
from lifx_emulator.api.routers.monitoring import create_monitoring_router
|
|
24
|
+
from lifx_emulator.api.routers.scenarios import create_scenarios_router
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Template directory for web UI
|
|
29
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
30
|
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_api_app(server: EmulatedLifxServer) -> FastAPI:
|
|
34
|
+
"""Create FastAPI application for emulator management.
|
|
35
|
+
|
|
36
|
+
This factory function assembles the complete API by:
|
|
37
|
+
1. Creating the FastAPI app with metadata
|
|
38
|
+
2. Including routers for monitoring, devices, and scenarios
|
|
39
|
+
3. Serving the embedded web UI at the root endpoint
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
server: The LIFX emulator server instance
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Configured FastAPI application
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> from lifx_emulator.server import EmulatedLifxServer
|
|
49
|
+
>>> server = EmulatedLifxServer(bind="127.0.0.1", port=56700)
|
|
50
|
+
>>> app = create_api_app(server)
|
|
51
|
+
>>> # Run with: uvicorn app:app --host 127.0.0.1 --port 8080
|
|
52
|
+
"""
|
|
53
|
+
app = FastAPI(
|
|
54
|
+
title="LIFX Emulator API",
|
|
55
|
+
description="""
|
|
56
|
+
Runtime management and monitoring API for LIFX device emulator.
|
|
57
|
+
|
|
58
|
+
This API provides read-only monitoring of the emulator state and device management
|
|
59
|
+
capabilities (add/remove devices). Device state changes must be performed via the
|
|
60
|
+
LIFX LAN protocol.
|
|
61
|
+
|
|
62
|
+
## Features
|
|
63
|
+
- Real-time server statistics and packet monitoring
|
|
64
|
+
- Device inspection and management
|
|
65
|
+
- Test scenario management for protocol testing
|
|
66
|
+
- Recent activity tracking
|
|
67
|
+
- OpenAPI 3.1.0 compliant schema
|
|
68
|
+
|
|
69
|
+
## Architecture
|
|
70
|
+
The API is organized into three main routers:
|
|
71
|
+
- **Monitoring**: Server stats and activity logs
|
|
72
|
+
- **Devices**: Device CRUD operations
|
|
73
|
+
- **Scenarios**: Test scenario configuration
|
|
74
|
+
""",
|
|
75
|
+
version="1.0.0",
|
|
76
|
+
contact={
|
|
77
|
+
"name": "LIFX Emulator",
|
|
78
|
+
"url": "https://github.com/Djelibeybi/lifx-emulator",
|
|
79
|
+
},
|
|
80
|
+
license_info={
|
|
81
|
+
"name": "UPL-1.0",
|
|
82
|
+
"url": "https://opensource.org/licenses/UPL",
|
|
83
|
+
},
|
|
84
|
+
openapi_tags=[
|
|
85
|
+
{
|
|
86
|
+
"name": "monitoring",
|
|
87
|
+
"description": "Server statistics and activity monitoring",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "devices",
|
|
91
|
+
"description": "Device management and inspection",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "scenarios",
|
|
95
|
+
"description": "Test scenario management",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
101
|
+
async def root(request: Request):
|
|
102
|
+
"""Serve embedded web UI dashboard."""
|
|
103
|
+
return templates.TemplateResponse(request, "dashboard.html")
|
|
104
|
+
|
|
105
|
+
# Include routers with server dependency injection
|
|
106
|
+
monitoring_router = create_monitoring_router(server)
|
|
107
|
+
devices_router = create_devices_router(server)
|
|
108
|
+
scenarios_router = create_scenarios_router(server)
|
|
109
|
+
|
|
110
|
+
app.include_router(monitoring_router)
|
|
111
|
+
app.include_router(devices_router)
|
|
112
|
+
app.include_router(scenarios_router)
|
|
113
|
+
|
|
114
|
+
logger.info(
|
|
115
|
+
"API application created with 3 routers (monitoring, devices, scenarios)"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return app
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def run_api_server(
|
|
122
|
+
server: EmulatedLifxServer, host: str = "127.0.0.1", port: int = 8080
|
|
123
|
+
):
|
|
124
|
+
"""Run the FastAPI server with uvicorn.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
server: The LIFX emulator server instance
|
|
128
|
+
host: Host to bind to (default: 127.0.0.1)
|
|
129
|
+
port: Port to bind to (default: 8080)
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
>>> import asyncio
|
|
133
|
+
>>> from lifx_emulator.server import EmulatedLifxServer
|
|
134
|
+
>>> server = EmulatedLifxServer(bind="127.0.0.1", port=56700)
|
|
135
|
+
>>> asyncio.run(run_api_server(server, host="0.0.0.0", port=8080))
|
|
136
|
+
"""
|
|
137
|
+
import uvicorn
|
|
138
|
+
|
|
139
|
+
app = create_api_app(server)
|
|
140
|
+
|
|
141
|
+
config = uvicorn.Config(
|
|
142
|
+
app,
|
|
143
|
+
host=host,
|
|
144
|
+
port=port,
|
|
145
|
+
log_level="info",
|
|
146
|
+
access_log=True,
|
|
147
|
+
)
|
|
148
|
+
api_server = uvicorn.Server(config)
|
|
149
|
+
|
|
150
|
+
logger.info("Starting API server on http://%s:%s", host, port)
|
|
151
|
+
logger.info("OpenAPI docs available at http://%s:%s/docs", host, port)
|
|
152
|
+
logger.info("ReDoc docs available at http://%s:%s/redoc", host, port)
|
|
153
|
+
|
|
154
|
+
await api_server.serve()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Mapper for converting EmulatedLifxDevice to DeviceInfo API model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from lifx_emulator.devices import EmulatedLifxDevice
|
|
9
|
+
|
|
10
|
+
from lifx_emulator.api.models import ColorHsbk, DeviceInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeviceMapper:
|
|
14
|
+
"""Maps domain device models to API response models.
|
|
15
|
+
|
|
16
|
+
This mapper eliminates duplication across multiple API endpoints by
|
|
17
|
+
providing a single, consistent way to convert device state to API responses.
|
|
18
|
+
|
|
19
|
+
**Eliminates**: 150+ lines of duplicated code across list_devices(),
|
|
20
|
+
get_device(), and create_device() endpoints.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def to_device_info(device: EmulatedLifxDevice) -> DeviceInfo:
|
|
25
|
+
"""Convert an EmulatedLifxDevice to a DeviceInfo API model.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
device: The emulated LIFX device to convert
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
DeviceInfo model ready for API response
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> device = create_color_light(serial="d073d5000001")
|
|
35
|
+
>>> info = DeviceMapper.to_device_info(device)
|
|
36
|
+
>>> info.serial
|
|
37
|
+
'd073d5000001'
|
|
38
|
+
"""
|
|
39
|
+
return DeviceInfo(
|
|
40
|
+
# Core identification
|
|
41
|
+
serial=device.state.serial,
|
|
42
|
+
label=device.state.label,
|
|
43
|
+
product=device.state.product,
|
|
44
|
+
vendor=device.state.vendor,
|
|
45
|
+
# Power state
|
|
46
|
+
power_level=device.state.power_level,
|
|
47
|
+
# Capability flags
|
|
48
|
+
has_color=device.state.has_color,
|
|
49
|
+
has_infrared=device.state.has_infrared,
|
|
50
|
+
has_multizone=device.state.has_multizone,
|
|
51
|
+
has_extended_multizone=device.state.has_extended_multizone,
|
|
52
|
+
has_matrix=device.state.has_matrix,
|
|
53
|
+
has_hev=device.state.has_hev,
|
|
54
|
+
# Zone/tile counts
|
|
55
|
+
zone_count=device.state.multizone.zone_count
|
|
56
|
+
if device.state.multizone is not None
|
|
57
|
+
else 0,
|
|
58
|
+
tile_count=device.state.matrix.tile_count
|
|
59
|
+
if device.state.matrix is not None
|
|
60
|
+
else 0,
|
|
61
|
+
# Color state (if applicable)
|
|
62
|
+
color=ColorHsbk(
|
|
63
|
+
hue=device.state.color.hue,
|
|
64
|
+
saturation=device.state.color.saturation,
|
|
65
|
+
brightness=device.state.color.brightness,
|
|
66
|
+
kelvin=device.state.color.kelvin,
|
|
67
|
+
)
|
|
68
|
+
if device.state.has_color
|
|
69
|
+
else None,
|
|
70
|
+
# Multizone colors (if applicable)
|
|
71
|
+
zone_colors=[
|
|
72
|
+
ColorHsbk(
|
|
73
|
+
hue=c.hue,
|
|
74
|
+
saturation=c.saturation,
|
|
75
|
+
brightness=c.brightness,
|
|
76
|
+
kelvin=c.kelvin,
|
|
77
|
+
)
|
|
78
|
+
for c in device.state.multizone.zone_colors
|
|
79
|
+
]
|
|
80
|
+
if device.state.multizone is not None
|
|
81
|
+
else [],
|
|
82
|
+
# Matrix/tile devices (if applicable)
|
|
83
|
+
tile_devices=device.state.matrix.tile_devices
|
|
84
|
+
if device.state.matrix is not None
|
|
85
|
+
else [],
|
|
86
|
+
# Firmware/version metadata
|
|
87
|
+
version_major=device.state.version_major,
|
|
88
|
+
version_minor=device.state.version_minor,
|
|
89
|
+
build_timestamp=device.state.build_timestamp,
|
|
90
|
+
# Location/group metadata
|
|
91
|
+
group_label=device.state.group.group_label,
|
|
92
|
+
location_label=device.state.location.location_label,
|
|
93
|
+
# Runtime metadata
|
|
94
|
+
uptime_ns=device.state.uptime_ns,
|
|
95
|
+
wifi_signal=device.state.wifi_signal,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def to_device_info_list(devices: list[EmulatedLifxDevice]) -> list[DeviceInfo]:
|
|
100
|
+
"""Convert a list of EmulatedLifxDevice to DeviceInfo API models.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
devices: List of emulated LIFX devices to convert
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
List of DeviceInfo models ready for API response
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
>>> devices = [create_color_light(), create_multizone_light()]
|
|
110
|
+
>>> info_list = DeviceMapper.to_device_info_list(devices)
|
|
111
|
+
>>> len(info_list)
|
|
112
|
+
2
|
|
113
|
+
"""
|
|
114
|
+
return [DeviceMapper.to_device_info(device) for device in devices]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Pydantic models for API requests and responses."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
|
|
5
|
+
# Import shared domain models
|
|
6
|
+
from lifx_emulator.scenarios import ScenarioConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeviceCreateRequest(BaseModel):
|
|
10
|
+
"""Request to create a new device."""
|
|
11
|
+
|
|
12
|
+
product_id: int = Field(
|
|
13
|
+
..., description="Product ID from LIFX registry", gt=0, lt=10000
|
|
14
|
+
)
|
|
15
|
+
serial: str | None = Field(
|
|
16
|
+
None,
|
|
17
|
+
description="Optional serial (auto-generated if not provided)",
|
|
18
|
+
min_length=12,
|
|
19
|
+
max_length=12,
|
|
20
|
+
)
|
|
21
|
+
zone_count: int | None = Field(
|
|
22
|
+
None, description="Number of zones for multizone devices", ge=0, le=1000
|
|
23
|
+
)
|
|
24
|
+
tile_count: int | None = Field(
|
|
25
|
+
None, description="Number of tiles for matrix devices", ge=0, le=100
|
|
26
|
+
)
|
|
27
|
+
tile_width: int | None = Field(
|
|
28
|
+
None, description="Width of each tile in pixels", ge=1, le=256
|
|
29
|
+
)
|
|
30
|
+
tile_height: int | None = Field(
|
|
31
|
+
None, description="Height of each tile in pixels", ge=1, le=256
|
|
32
|
+
)
|
|
33
|
+
firmware_major: int | None = Field(
|
|
34
|
+
None, description="Firmware major version", ge=0, le=255
|
|
35
|
+
)
|
|
36
|
+
firmware_minor: int | None = Field(
|
|
37
|
+
None, description="Firmware minor version", ge=0, le=255
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@field_validator("serial")
|
|
41
|
+
@classmethod
|
|
42
|
+
def validate_serial_format(cls, v: str | None) -> str | None:
|
|
43
|
+
"""Validate serial number format (12 hex characters)."""
|
|
44
|
+
if v is None:
|
|
45
|
+
return v
|
|
46
|
+
if len(v) != 12:
|
|
47
|
+
raise ValueError("Serial must be exactly 12 characters")
|
|
48
|
+
try:
|
|
49
|
+
# Validate it's valid hexadecimal by parsing as base-16 integer
|
|
50
|
+
int(v, 16)
|
|
51
|
+
except ValueError as e:
|
|
52
|
+
raise ValueError("Serial must be valid hexadecimal (0-9, a-f, A-F)") from e
|
|
53
|
+
return v.lower() # Normalize to lowercase
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ColorHsbk(BaseModel):
|
|
57
|
+
"""HSBK color representation."""
|
|
58
|
+
|
|
59
|
+
hue: int = Field(..., ge=0, le=65535, description="Hue (0-65535)")
|
|
60
|
+
saturation: int = Field(..., ge=0, le=65535, description="Saturation (0-65535)")
|
|
61
|
+
brightness: int = Field(..., ge=0, le=65535, description="Brightness (0-65535)")
|
|
62
|
+
kelvin: int = Field(
|
|
63
|
+
..., ge=1500, le=9000, description="Color temperature in Kelvin (1500-9000)"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DeviceInfo(BaseModel):
|
|
68
|
+
"""Device information response."""
|
|
69
|
+
|
|
70
|
+
serial: str
|
|
71
|
+
label: str
|
|
72
|
+
product: int
|
|
73
|
+
vendor: int
|
|
74
|
+
power_level: int
|
|
75
|
+
has_color: bool
|
|
76
|
+
has_infrared: bool
|
|
77
|
+
has_multizone: bool
|
|
78
|
+
has_extended_multizone: bool
|
|
79
|
+
has_matrix: bool
|
|
80
|
+
has_hev: bool
|
|
81
|
+
zone_count: int
|
|
82
|
+
tile_count: int
|
|
83
|
+
color: ColorHsbk | None = None
|
|
84
|
+
zone_colors: list[ColorHsbk] = Field(default_factory=list)
|
|
85
|
+
tile_devices: list[dict] = Field(default_factory=list)
|
|
86
|
+
# Metadata fields
|
|
87
|
+
version_major: int = 0
|
|
88
|
+
version_minor: int = 0
|
|
89
|
+
build_timestamp: int = 0
|
|
90
|
+
group_label: str = ""
|
|
91
|
+
location_label: str = ""
|
|
92
|
+
uptime_ns: int = 0
|
|
93
|
+
wifi_signal: float = 0.0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ServerStats(BaseModel):
|
|
97
|
+
"""Server statistics response."""
|
|
98
|
+
|
|
99
|
+
uptime_seconds: float
|
|
100
|
+
start_time: float
|
|
101
|
+
device_count: int
|
|
102
|
+
packets_received: int
|
|
103
|
+
packets_sent: int
|
|
104
|
+
packets_received_by_type: dict[int, int]
|
|
105
|
+
packets_sent_by_type: dict[int, int]
|
|
106
|
+
error_count: int
|
|
107
|
+
activity_enabled: bool
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ActivityEvent(BaseModel):
|
|
111
|
+
"""Recent activity event."""
|
|
112
|
+
|
|
113
|
+
timestamp: float
|
|
114
|
+
direction: str
|
|
115
|
+
packet_type: int
|
|
116
|
+
packet_name: str
|
|
117
|
+
device: str | None = None
|
|
118
|
+
target: str | None = None
|
|
119
|
+
addr: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ScenarioResponse(BaseModel):
|
|
123
|
+
"""Response model for scenario operations."""
|
|
124
|
+
|
|
125
|
+
scope: str = Field(
|
|
126
|
+
..., description="Scope of the scenario (global, device, type, location, group)"
|
|
127
|
+
)
|
|
128
|
+
identifier: str | None = Field(
|
|
129
|
+
None, description="Identifier for the scope (serial, type name, etc.)"
|
|
130
|
+
)
|
|
131
|
+
scenario: ScenarioConfig | None = Field(
|
|
132
|
+
None, description="The scenario configuration (None if not set)"
|
|
133
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""API routers for LIFX emulator endpoints."""
|
|
2
|
+
|
|
3
|
+
from lifx_emulator.api.routers.devices import create_devices_router
|
|
4
|
+
from lifx_emulator.api.routers.monitoring import create_monitoring_router
|
|
5
|
+
from lifx_emulator.api.routers.scenarios import create_scenarios_router
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"create_monitoring_router",
|
|
9
|
+
"create_devices_router",
|
|
10
|
+
"create_scenarios_router",
|
|
11
|
+
]
|