lifx-emulator 1.0.2__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lifx_emulator/__init__.py +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 +333 -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 +1 -1
- 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 +115 -61
- lifx_emulator/protocol/generator.py +18 -5
- lifx_emulator/protocol/packets.py +7 -7
- 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 +38 -64
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/METADATA +1 -1
- lifx_emulator-2.0.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.0.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/entry_points.txt +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Device management endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from lifx_emulator.server import EmulatedLifxServer
|
|
11
|
+
|
|
12
|
+
from lifx_emulator.api.models import DeviceCreateRequest, DeviceInfo
|
|
13
|
+
from lifx_emulator.api.services.device_service import (
|
|
14
|
+
DeviceAlreadyExistsError,
|
|
15
|
+
DeviceCreationError,
|
|
16
|
+
DeviceNotFoundError,
|
|
17
|
+
DeviceService,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_devices_router(server: EmulatedLifxServer) -> APIRouter:
|
|
22
|
+
"""Create devices router with server dependency.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
server: The LIFX emulator server instance
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Configured APIRouter for device endpoints
|
|
29
|
+
"""
|
|
30
|
+
# Create fresh router instance for this server
|
|
31
|
+
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
|
32
|
+
|
|
33
|
+
# Create service layer
|
|
34
|
+
device_service = DeviceService(server)
|
|
35
|
+
|
|
36
|
+
@router.get(
|
|
37
|
+
"",
|
|
38
|
+
response_model=list[DeviceInfo],
|
|
39
|
+
summary="List all devices",
|
|
40
|
+
description=(
|
|
41
|
+
"Returns a list of all emulated devices with their current configuration."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
async def list_devices():
|
|
45
|
+
"""List all emulated devices."""
|
|
46
|
+
return device_service.list_all_devices()
|
|
47
|
+
|
|
48
|
+
@router.get(
|
|
49
|
+
"/{serial}",
|
|
50
|
+
response_model=DeviceInfo,
|
|
51
|
+
summary="Get device information",
|
|
52
|
+
description=(
|
|
53
|
+
"Returns detailed information about a specific device by its serial number."
|
|
54
|
+
),
|
|
55
|
+
responses={
|
|
56
|
+
404: {"description": "Device not found"},
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
async def get_device(serial: str):
|
|
60
|
+
"""Get specific device information."""
|
|
61
|
+
try:
|
|
62
|
+
return device_service.get_device_info(serial)
|
|
63
|
+
except DeviceNotFoundError as e:
|
|
64
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
65
|
+
|
|
66
|
+
@router.post(
|
|
67
|
+
"",
|
|
68
|
+
response_model=DeviceInfo,
|
|
69
|
+
status_code=201,
|
|
70
|
+
summary="Create a new device",
|
|
71
|
+
description=(
|
|
72
|
+
"Creates a new emulated device by product ID. "
|
|
73
|
+
"The device will be added to the emulator immediately."
|
|
74
|
+
),
|
|
75
|
+
responses={
|
|
76
|
+
201: {"description": "Device created successfully"},
|
|
77
|
+
400: {"description": "Invalid product ID or parameters"},
|
|
78
|
+
409: {"description": "Device with this serial already exists"},
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
async def create_device(request: DeviceCreateRequest):
|
|
82
|
+
"""Create a new device."""
|
|
83
|
+
try:
|
|
84
|
+
return device_service.create_device(request)
|
|
85
|
+
except DeviceCreationError as e:
|
|
86
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
87
|
+
except DeviceAlreadyExistsError as e:
|
|
88
|
+
raise HTTPException(status_code=409, detail=str(e))
|
|
89
|
+
|
|
90
|
+
@router.delete(
|
|
91
|
+
"/{serial}",
|
|
92
|
+
status_code=204,
|
|
93
|
+
summary="Delete a device",
|
|
94
|
+
description=(
|
|
95
|
+
"Removes an emulated device from the server. "
|
|
96
|
+
"The device will stop responding to LIFX protocol packets."
|
|
97
|
+
),
|
|
98
|
+
responses={
|
|
99
|
+
204: {"description": "Device deleted successfully"},
|
|
100
|
+
404: {"description": "Device not found"},
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
async def delete_device(serial: str):
|
|
104
|
+
"""Delete a device."""
|
|
105
|
+
try:
|
|
106
|
+
device_service.delete_device(serial)
|
|
107
|
+
except DeviceNotFoundError as e:
|
|
108
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
109
|
+
|
|
110
|
+
@router.delete(
|
|
111
|
+
"",
|
|
112
|
+
status_code=200,
|
|
113
|
+
summary="Delete all devices",
|
|
114
|
+
description=(
|
|
115
|
+
"Removes all emulated devices from the server. "
|
|
116
|
+
"All devices will stop responding to LIFX protocol packets."
|
|
117
|
+
),
|
|
118
|
+
responses={
|
|
119
|
+
200: {"description": "All devices deleted successfully"},
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
async def delete_all_devices():
|
|
123
|
+
"""Delete all devices from the running server."""
|
|
124
|
+
count = device_service.clear_all_devices(delete_storage=False)
|
|
125
|
+
return {"deleted": count, "message": f"Removed {count} device(s) from server"}
|
|
126
|
+
|
|
127
|
+
# TODO: Add storage clear endpoint (was /api/storage in old API, not under /devices)
|
|
128
|
+
# This should be handled separately or at the app level
|
|
129
|
+
|
|
130
|
+
return router
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Monitoring endpoints for server statistics and activity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from lifx_emulator.server import EmulatedLifxServer
|
|
11
|
+
|
|
12
|
+
from lifx_emulator.api.models import ActivityEvent, ServerStats
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_monitoring_router(server: EmulatedLifxServer) -> APIRouter:
|
|
16
|
+
"""Create monitoring router with server dependency.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
server: The LIFX emulator server instance
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Configured APIRouter for monitoring endpoints
|
|
23
|
+
"""
|
|
24
|
+
# Create fresh router instance for this server
|
|
25
|
+
router = APIRouter(prefix="/api", tags=["monitoring"])
|
|
26
|
+
|
|
27
|
+
@router.get(
|
|
28
|
+
"/stats",
|
|
29
|
+
response_model=ServerStats,
|
|
30
|
+
summary="Get server statistics",
|
|
31
|
+
description=(
|
|
32
|
+
"Returns server uptime, packet counts, error counts, and device count."
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
async def get_stats():
|
|
36
|
+
"""Get server statistics."""
|
|
37
|
+
return server.get_stats()
|
|
38
|
+
|
|
39
|
+
@router.get(
|
|
40
|
+
"/activity",
|
|
41
|
+
response_model=list[ActivityEvent],
|
|
42
|
+
summary="Get recent activity",
|
|
43
|
+
description=(
|
|
44
|
+
"Returns the last 100 packet events (TX/RX) "
|
|
45
|
+
"with timestamps and packet details."
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
async def get_activity():
|
|
49
|
+
"""Get recent activity events."""
|
|
50
|
+
return [ActivityEvent(**event) for event in server.get_recent_activity()]
|
|
51
|
+
|
|
52
|
+
return router
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Scenario management endpoints for testing LIFX protocol behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from lifx_emulator.server import EmulatedLifxServer
|
|
11
|
+
|
|
12
|
+
from lifx_emulator.api.models import ScenarioConfig, ScenarioResponse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate_device_serial(serial: str) -> bool:
|
|
16
|
+
"""Validate that serial is a properly formatted 12-character hex string."""
|
|
17
|
+
return len(serial) == 12 and all(c in "0123456789abcdefABCDEF" for c in serial)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _add_global_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_fn):
|
|
21
|
+
"""Add global scenario endpoints to router."""
|
|
22
|
+
|
|
23
|
+
@router.get(
|
|
24
|
+
"/global",
|
|
25
|
+
response_model=ScenarioResponse,
|
|
26
|
+
summary="Get global scenario",
|
|
27
|
+
description=(
|
|
28
|
+
"Returns the global scenario that applies to all devices as a baseline."
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
async def get_global_scenario():
|
|
32
|
+
config = server.scenario_manager.get_global_scenario()
|
|
33
|
+
return ScenarioResponse(scope="global", identifier=None, scenario=config)
|
|
34
|
+
|
|
35
|
+
@router.put(
|
|
36
|
+
"/global",
|
|
37
|
+
response_model=ScenarioResponse,
|
|
38
|
+
summary="Set global scenario",
|
|
39
|
+
description=(
|
|
40
|
+
"Sets the global scenario that applies to all devices as a baseline."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
async def set_global_scenario(scenario: ScenarioConfig):
|
|
44
|
+
server.scenario_manager.set_global_scenario(scenario)
|
|
45
|
+
await persist_fn()
|
|
46
|
+
return ScenarioResponse(scope="global", identifier=None, scenario=scenario)
|
|
47
|
+
|
|
48
|
+
@router.delete(
|
|
49
|
+
"/global",
|
|
50
|
+
status_code=204,
|
|
51
|
+
summary="Clear global scenario",
|
|
52
|
+
description="Clears the global scenario, resetting it to defaults.",
|
|
53
|
+
)
|
|
54
|
+
async def clear_global_scenario():
|
|
55
|
+
server.scenario_manager.clear_global_scenario()
|
|
56
|
+
await persist_fn()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _add_device_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_fn):
|
|
60
|
+
"""Add device-specific scenario endpoints to router."""
|
|
61
|
+
|
|
62
|
+
@router.get(
|
|
63
|
+
"/devices/{serial}",
|
|
64
|
+
response_model=ScenarioResponse,
|
|
65
|
+
summary="Get device scenario",
|
|
66
|
+
description="Returns the scenario for a specific device by serial number.",
|
|
67
|
+
responses={404: {"description": "Device scenario not set"}},
|
|
68
|
+
)
|
|
69
|
+
async def get_device_scenario(serial: str):
|
|
70
|
+
config = server.scenario_manager.get_device_scenario(serial)
|
|
71
|
+
if config is None:
|
|
72
|
+
raise HTTPException(404, f"No scenario set for device {serial}")
|
|
73
|
+
return ScenarioResponse(scope="device", identifier=serial, scenario=config)
|
|
74
|
+
|
|
75
|
+
@router.put(
|
|
76
|
+
"/devices/{serial}",
|
|
77
|
+
response_model=ScenarioResponse,
|
|
78
|
+
summary="Set device scenario",
|
|
79
|
+
description="Sets the scenario for a specific device by serial number.",
|
|
80
|
+
responses={404: {"description": "Invalid device serial format"}},
|
|
81
|
+
)
|
|
82
|
+
async def set_device_scenario(serial: str, scenario: ScenarioConfig):
|
|
83
|
+
if not _validate_device_serial(serial):
|
|
84
|
+
raise HTTPException(404, f"Invalid device serial format: {serial}.")
|
|
85
|
+
server.scenario_manager.set_device_scenario(serial, scenario)
|
|
86
|
+
await persist_fn()
|
|
87
|
+
return ScenarioResponse(scope="device", identifier=serial, scenario=scenario)
|
|
88
|
+
|
|
89
|
+
@router.delete(
|
|
90
|
+
"/devices/{serial}",
|
|
91
|
+
status_code=204,
|
|
92
|
+
summary="Clear device scenario",
|
|
93
|
+
description="Clears the scenario for a specific device.",
|
|
94
|
+
responses={404: {"description": "Device scenario not found"}},
|
|
95
|
+
)
|
|
96
|
+
async def clear_device_scenario(serial: str):
|
|
97
|
+
if not server.scenario_manager.delete_device_scenario(serial):
|
|
98
|
+
raise HTTPException(404, f"No scenario set for device {serial}")
|
|
99
|
+
await persist_fn()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _add_type_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_fn):
|
|
103
|
+
"""Add type-specific scenario endpoints to router."""
|
|
104
|
+
|
|
105
|
+
@router.get(
|
|
106
|
+
"/types/{device_type}",
|
|
107
|
+
response_model=ScenarioResponse,
|
|
108
|
+
summary="Get type scenario",
|
|
109
|
+
description="Returns the scenario for all devices of a specific type.",
|
|
110
|
+
responses={404: {"description": "Type scenario not set"}},
|
|
111
|
+
)
|
|
112
|
+
async def get_type_scenario(device_type: str):
|
|
113
|
+
config = server.scenario_manager.get_type_scenario(device_type)
|
|
114
|
+
if config is None:
|
|
115
|
+
raise HTTPException(404, f"No scenario set for type {device_type}")
|
|
116
|
+
return ScenarioResponse(scope="type", identifier=device_type, scenario=config)
|
|
117
|
+
|
|
118
|
+
@router.put(
|
|
119
|
+
"/types/{device_type}",
|
|
120
|
+
response_model=ScenarioResponse,
|
|
121
|
+
summary="Set type scenario",
|
|
122
|
+
description="Sets the scenario for all devices of a specific type.",
|
|
123
|
+
)
|
|
124
|
+
async def set_type_scenario(device_type: str, scenario: ScenarioConfig):
|
|
125
|
+
server.scenario_manager.set_type_scenario(device_type, scenario)
|
|
126
|
+
await persist_fn()
|
|
127
|
+
return ScenarioResponse(scope="type", identifier=device_type, scenario=scenario)
|
|
128
|
+
|
|
129
|
+
@router.delete(
|
|
130
|
+
"/types/{device_type}",
|
|
131
|
+
status_code=204,
|
|
132
|
+
summary="Clear type scenario",
|
|
133
|
+
description="Clears the scenario for a device type.",
|
|
134
|
+
responses={404: {"description": "Type scenario not found"}},
|
|
135
|
+
)
|
|
136
|
+
async def clear_type_scenario(device_type: str):
|
|
137
|
+
if not server.scenario_manager.delete_type_scenario(device_type):
|
|
138
|
+
raise HTTPException(404, f"No scenario set for type {device_type}")
|
|
139
|
+
await persist_fn()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _add_location_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_fn):
|
|
143
|
+
"""Add location-based scenario endpoints to router."""
|
|
144
|
+
|
|
145
|
+
@router.get(
|
|
146
|
+
"/locations/{location}",
|
|
147
|
+
response_model=ScenarioResponse,
|
|
148
|
+
summary="Get location scenario",
|
|
149
|
+
description="Returns the scenario for all devices in a location.",
|
|
150
|
+
responses={404: {"description": "Location scenario not set"}},
|
|
151
|
+
)
|
|
152
|
+
async def get_location_scenario(location: str):
|
|
153
|
+
config = server.scenario_manager.get_location_scenario(location)
|
|
154
|
+
if config is None:
|
|
155
|
+
raise HTTPException(404, f"No scenario set for location {location}")
|
|
156
|
+
return ScenarioResponse(scope="location", identifier=location, scenario=config)
|
|
157
|
+
|
|
158
|
+
@router.put(
|
|
159
|
+
"/locations/{location}",
|
|
160
|
+
response_model=ScenarioResponse,
|
|
161
|
+
summary="Set location scenario",
|
|
162
|
+
description="Sets the scenario for all devices in a location.",
|
|
163
|
+
)
|
|
164
|
+
async def set_location_scenario(location: str, scenario: ScenarioConfig):
|
|
165
|
+
server.scenario_manager.set_location_scenario(location, scenario)
|
|
166
|
+
await persist_fn()
|
|
167
|
+
return ScenarioResponse(
|
|
168
|
+
scope="location", identifier=location, scenario=scenario
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@router.delete(
|
|
172
|
+
"/locations/{location}",
|
|
173
|
+
status_code=204,
|
|
174
|
+
summary="Clear location scenario",
|
|
175
|
+
description="Clears the scenario for a location.",
|
|
176
|
+
responses={404: {"description": "Location scenario not found"}},
|
|
177
|
+
)
|
|
178
|
+
async def clear_location_scenario(location: str):
|
|
179
|
+
if not server.scenario_manager.delete_location_scenario(location):
|
|
180
|
+
raise HTTPException(404, f"No scenario set for location {location}")
|
|
181
|
+
await persist_fn()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _add_group_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_fn):
|
|
185
|
+
"""Add group-based scenario endpoints to router."""
|
|
186
|
+
|
|
187
|
+
@router.get(
|
|
188
|
+
"/groups/{group}",
|
|
189
|
+
response_model=ScenarioResponse,
|
|
190
|
+
summary="Get group scenario",
|
|
191
|
+
description="Returns the scenario for all devices in a group.",
|
|
192
|
+
responses={404: {"description": "Group scenario not set"}},
|
|
193
|
+
)
|
|
194
|
+
async def get_group_scenario(group: str):
|
|
195
|
+
config = server.scenario_manager.get_group_scenario(group)
|
|
196
|
+
if config is None:
|
|
197
|
+
raise HTTPException(404, f"No scenario set for group {group}")
|
|
198
|
+
return ScenarioResponse(scope="group", identifier=group, scenario=config)
|
|
199
|
+
|
|
200
|
+
@router.put(
|
|
201
|
+
"/groups/{group}",
|
|
202
|
+
response_model=ScenarioResponse,
|
|
203
|
+
summary="Set group scenario",
|
|
204
|
+
description="Sets the scenario for all devices in a group.",
|
|
205
|
+
)
|
|
206
|
+
async def set_group_scenario(group: str, scenario: ScenarioConfig):
|
|
207
|
+
server.scenario_manager.set_group_scenario(group, scenario)
|
|
208
|
+
await persist_fn()
|
|
209
|
+
return ScenarioResponse(scope="group", identifier=group, scenario=scenario)
|
|
210
|
+
|
|
211
|
+
@router.delete(
|
|
212
|
+
"/groups/{group}",
|
|
213
|
+
status_code=204,
|
|
214
|
+
summary="Clear group scenario",
|
|
215
|
+
description="Clears the scenario for a group.",
|
|
216
|
+
responses={404: {"description": "Group scenario not found"}},
|
|
217
|
+
)
|
|
218
|
+
async def clear_group_scenario(group: str):
|
|
219
|
+
if not server.scenario_manager.delete_group_scenario(group):
|
|
220
|
+
raise HTTPException(404, f"No scenario set for group {group}")
|
|
221
|
+
await persist_fn()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def create_scenarios_router(server: EmulatedLifxServer) -> APIRouter:
|
|
225
|
+
"""Create scenarios router with server dependency.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
server: The LIFX emulator server instance
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Configured APIRouter for scenario endpoints
|
|
232
|
+
"""
|
|
233
|
+
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
|
234
|
+
|
|
235
|
+
async def persist():
|
|
236
|
+
"""Helper to invalidate device caches and persist scenarios."""
|
|
237
|
+
server.invalidate_all_scenario_caches()
|
|
238
|
+
if server.scenario_persistence:
|
|
239
|
+
await server.scenario_persistence.save(server.scenario_manager)
|
|
240
|
+
|
|
241
|
+
_add_global_endpoints(router, server, persist)
|
|
242
|
+
_add_device_endpoints(router, server, persist)
|
|
243
|
+
_add_type_endpoints(router, server, persist)
|
|
244
|
+
_add_location_endpoints(router, server, persist)
|
|
245
|
+
_add_group_endpoints(router, server, persist)
|
|
246
|
+
|
|
247
|
+
return router
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Business logic services for API endpoints."""
|
|
2
|
+
|
|
3
|
+
from lifx_emulator.api.services.device_service import DeviceService
|
|
4
|
+
|
|
5
|
+
# TODO: Create ScenarioService (Phase 1.1b completion)
|
|
6
|
+
# from lifx_emulator.api.services.scenario_service import ScenarioService
|
|
7
|
+
|
|
8
|
+
__all__ = ["DeviceService"]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Device management business logic service.
|
|
2
|
+
|
|
3
|
+
Separates API handlers from server operations, providing a clean service layer
|
|
4
|
+
for device CRUD operations. Applies Single Responsibility Principle by keeping
|
|
5
|
+
business logic separate from HTTP concerns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from lifx_emulator.server import EmulatedLifxServer
|
|
15
|
+
|
|
16
|
+
from lifx_emulator.api.mappers import DeviceMapper
|
|
17
|
+
from lifx_emulator.api.models import DeviceCreateRequest, DeviceInfo
|
|
18
|
+
from lifx_emulator.factories import create_device
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DeviceNotFoundError(Exception):
|
|
24
|
+
"""Raised when a device with the specified serial is not found."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, serial: str):
|
|
27
|
+
super().__init__(f"Device {serial} not found")
|
|
28
|
+
self.serial = serial
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DeviceAlreadyExistsError(Exception):
|
|
32
|
+
"""Raised when attempting to create a device with a serial that already exists."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, serial: str):
|
|
35
|
+
super().__init__(f"Device with serial {serial} already exists")
|
|
36
|
+
self.serial = serial
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeviceCreationError(Exception):
|
|
40
|
+
"""Raised when device creation fails."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DeviceService:
|
|
46
|
+
"""Service for managing emulated LIFX devices.
|
|
47
|
+
|
|
48
|
+
Provides business logic for device operations:
|
|
49
|
+
- Listing all devices
|
|
50
|
+
- Getting individual device info
|
|
51
|
+
- Creating new devices
|
|
52
|
+
- Deleting devices
|
|
53
|
+
- Clearing all devices
|
|
54
|
+
|
|
55
|
+
**Benefits**:
|
|
56
|
+
- Separates business logic from HTTP/API concerns
|
|
57
|
+
- Testable without FastAPI dependencies
|
|
58
|
+
- Consistent error handling
|
|
59
|
+
- Single source of truth for device operations
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, server: EmulatedLifxServer):
|
|
63
|
+
"""Initialize the device service.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
server: The LIFX emulator server instance to manage devices for
|
|
67
|
+
"""
|
|
68
|
+
self.server = server
|
|
69
|
+
|
|
70
|
+
def list_all_devices(self) -> list[DeviceInfo]:
|
|
71
|
+
"""Get information about all emulated devices.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of DeviceInfo objects for all devices
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> service = DeviceService(server)
|
|
78
|
+
>>> devices = service.list_all_devices()
|
|
79
|
+
>>> len(devices)
|
|
80
|
+
3
|
|
81
|
+
"""
|
|
82
|
+
devices = self.server.get_all_devices()
|
|
83
|
+
return DeviceMapper.to_device_info_list(devices)
|
|
84
|
+
|
|
85
|
+
def get_device_info(self, serial: str) -> DeviceInfo:
|
|
86
|
+
"""Get information about a specific device.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
serial: The device serial number (12-character hex string)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
DeviceInfo object for the device
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
DeviceNotFoundError: If no device with the given serial exists
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
>>> service = DeviceService(server)
|
|
99
|
+
>>> info = service.get_device_info("d073d5000001")
|
|
100
|
+
>>> info.label
|
|
101
|
+
'LIFX Bulb'
|
|
102
|
+
"""
|
|
103
|
+
device = self.server.get_device(serial)
|
|
104
|
+
if not device:
|
|
105
|
+
raise DeviceNotFoundError(serial)
|
|
106
|
+
|
|
107
|
+
return DeviceMapper.to_device_info(device)
|
|
108
|
+
|
|
109
|
+
def create_device(self, request: DeviceCreateRequest) -> DeviceInfo:
|
|
110
|
+
"""Create a new emulated device.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
request: Device creation request with product_id and optional parameters
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
DeviceInfo object for the newly created device
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
DeviceCreationError: If device creation fails
|
|
120
|
+
DeviceAlreadyExistsError: If a device with the serial already exists
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
>>> service = DeviceService(server)
|
|
124
|
+
>>> request = DeviceCreateRequest(product_id=27, serial="d073d5000001")
|
|
125
|
+
>>> info = service.create_device(request)
|
|
126
|
+
>>> info.product
|
|
127
|
+
27
|
|
128
|
+
"""
|
|
129
|
+
# Build firmware version tuple if provided
|
|
130
|
+
firmware_version = None
|
|
131
|
+
if request.firmware_major is not None and request.firmware_minor is not None:
|
|
132
|
+
firmware_version = (request.firmware_major, request.firmware_minor)
|
|
133
|
+
|
|
134
|
+
# Create the device using the factory
|
|
135
|
+
try:
|
|
136
|
+
device = create_device(
|
|
137
|
+
product_id=request.product_id,
|
|
138
|
+
serial=request.serial,
|
|
139
|
+
zone_count=request.zone_count,
|
|
140
|
+
tile_count=request.tile_count,
|
|
141
|
+
tile_width=request.tile_width,
|
|
142
|
+
tile_height=request.tile_height,
|
|
143
|
+
firmware_version=firmware_version,
|
|
144
|
+
storage=self.server.storage,
|
|
145
|
+
scenario_manager=self.server.scenario_manager,
|
|
146
|
+
)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.error("Failed to create device: %s", e, exc_info=True)
|
|
149
|
+
raise DeviceCreationError(f"Failed to create device: {e}") from e
|
|
150
|
+
|
|
151
|
+
# Add device to server
|
|
152
|
+
if not self.server.add_device(device):
|
|
153
|
+
raise DeviceAlreadyExistsError(device.state.serial)
|
|
154
|
+
|
|
155
|
+
logger.info(
|
|
156
|
+
"Created device: serial=%s product=%s",
|
|
157
|
+
device.state.serial,
|
|
158
|
+
device.state.product,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return DeviceMapper.to_device_info(device)
|
|
162
|
+
|
|
163
|
+
def delete_device(self, serial: str) -> None:
|
|
164
|
+
"""Delete an emulated device.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
serial: The device serial number to delete
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
DeviceNotFoundError: If no device with the given serial exists
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
>>> service = DeviceService(server)
|
|
174
|
+
>>> service.delete_device("d073d5000001")
|
|
175
|
+
"""
|
|
176
|
+
if not self.server.remove_device(serial):
|
|
177
|
+
raise DeviceNotFoundError(serial)
|
|
178
|
+
|
|
179
|
+
logger.info("Deleted device: serial=%s", serial)
|
|
180
|
+
|
|
181
|
+
def clear_all_devices(self, delete_storage: bool = False) -> int:
|
|
182
|
+
"""Remove all emulated devices from the server.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
delete_storage: If True, also delete persistent storage for devices
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The number of devices removed
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
>>> service = DeviceService(server)
|
|
192
|
+
>>> count = service.clear_all_devices()
|
|
193
|
+
>>> count
|
|
194
|
+
5
|
|
195
|
+
"""
|
|
196
|
+
count = self.server.remove_all_devices(delete_storage=delete_storage)
|
|
197
|
+
logger.info("Cleared %d devices (delete_storage=%s)", count, delete_storage)
|
|
198
|
+
return count
|