lifx-emulator 3.1.0__py3-none-any.whl → 4.2.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-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/METADATA +2 -1
- lifx_emulator-4.2.0.dist-info/RECORD +43 -0
- lifx_emulator_app/__main__.py +693 -137
- lifx_emulator_app/api/__init__.py +0 -4
- lifx_emulator_app/api/app.py +122 -16
- lifx_emulator_app/api/models.py +32 -1
- lifx_emulator_app/api/routers/__init__.py +5 -1
- lifx_emulator_app/api/routers/devices.py +64 -10
- lifx_emulator_app/api/routers/products.py +42 -0
- lifx_emulator_app/api/routers/scenarios.py +55 -52
- lifx_emulator_app/api/routers/websocket.py +70 -0
- lifx_emulator_app/api/services/__init__.py +21 -4
- lifx_emulator_app/api/services/device_service.py +188 -1
- lifx_emulator_app/api/services/event_bridge.py +234 -0
- lifx_emulator_app/api/services/scenario_service.py +153 -0
- lifx_emulator_app/api/services/websocket_manager.py +326 -0
- lifx_emulator_app/api/static/_app/env.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css +1 -0
- lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js +2 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js +2 -0
- lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js +6 -0
- lifx_emulator_app/api/static/_app/version.json +1 -0
- lifx_emulator_app/api/static/index.html +38 -0
- lifx_emulator_app/api/static/robots.txt +3 -0
- lifx_emulator_app/config.py +316 -0
- lifx_emulator-3.1.0.dist-info/RECORD +0 -19
- lifx_emulator_app/api/static/dashboard.js +0 -588
- lifx_emulator_app/api/templates/dashboard.html +0 -357
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -9,15 +9,17 @@ from fastapi import APIRouter, HTTPException
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from lifx_emulator.server import EmulatedLifxServer
|
|
11
11
|
|
|
12
|
-
from lifx_emulator_app.api.
|
|
13
|
-
|
|
12
|
+
from lifx_emulator_app.api.services.websocket_manager import WebSocketManager
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
from lifx_emulator_app.api.models import ScenarioConfig, ScenarioResponse
|
|
15
|
+
from lifx_emulator_app.api.services.scenario_service import (
|
|
16
|
+
InvalidDeviceSerialError,
|
|
17
|
+
ScenarioNotFoundError,
|
|
18
|
+
ScenarioService,
|
|
19
|
+
)
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
def _add_global_endpoints(router: APIRouter,
|
|
22
|
+
def _add_global_endpoints(router: APIRouter, service: ScenarioService):
|
|
21
23
|
"""Add global scenario endpoints to router."""
|
|
22
24
|
|
|
23
25
|
@router.get(
|
|
@@ -29,7 +31,7 @@ def _add_global_endpoints(router: APIRouter, server: EmulatedLifxServer, persist
|
|
|
29
31
|
),
|
|
30
32
|
)
|
|
31
33
|
async def get_global_scenario():
|
|
32
|
-
config =
|
|
34
|
+
config = service.get_global_scenario()
|
|
33
35
|
return ScenarioResponse(scope="global", identifier=None, scenario=config)
|
|
34
36
|
|
|
35
37
|
@router.put(
|
|
@@ -41,8 +43,7 @@ def _add_global_endpoints(router: APIRouter, server: EmulatedLifxServer, persist
|
|
|
41
43
|
),
|
|
42
44
|
)
|
|
43
45
|
async def set_global_scenario(scenario: ScenarioConfig):
|
|
44
|
-
|
|
45
|
-
await persist_fn()
|
|
46
|
+
await service.set_global_scenario(scenario)
|
|
46
47
|
return ScenarioResponse(scope="global", identifier=None, scenario=scenario)
|
|
47
48
|
|
|
48
49
|
@router.delete(
|
|
@@ -52,11 +53,10 @@ def _add_global_endpoints(router: APIRouter, server: EmulatedLifxServer, persist
|
|
|
52
53
|
description="Clears the global scenario, resetting it to defaults.",
|
|
53
54
|
)
|
|
54
55
|
async def clear_global_scenario():
|
|
55
|
-
|
|
56
|
-
await persist_fn()
|
|
56
|
+
await service.clear_global_scenario()
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def _add_device_endpoints(router: APIRouter,
|
|
59
|
+
def _add_device_endpoints(router: APIRouter, service: ScenarioService):
|
|
60
60
|
"""Add device-specific scenario endpoints to router."""
|
|
61
61
|
|
|
62
62
|
@router.get(
|
|
@@ -67,8 +67,9 @@ def _add_device_endpoints(router: APIRouter, server: EmulatedLifxServer, persist
|
|
|
67
67
|
responses={404: {"description": "Device scenario not set"}},
|
|
68
68
|
)
|
|
69
69
|
async def get_device_scenario(serial: str):
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
try:
|
|
71
|
+
config = service.get_scope_scenario("device", serial)
|
|
72
|
+
except ScenarioNotFoundError:
|
|
72
73
|
raise HTTPException(404, f"No scenario set for device {serial}")
|
|
73
74
|
return ScenarioResponse(scope="device", identifier=serial, scenario=config)
|
|
74
75
|
|
|
@@ -80,10 +81,10 @@ def _add_device_endpoints(router: APIRouter, server: EmulatedLifxServer, persist
|
|
|
80
81
|
responses={404: {"description": "Invalid device serial format"}},
|
|
81
82
|
)
|
|
82
83
|
async def set_device_scenario(serial: str, scenario: ScenarioConfig):
|
|
83
|
-
|
|
84
|
+
try:
|
|
85
|
+
await service.set_scope_scenario("device", serial, scenario)
|
|
86
|
+
except InvalidDeviceSerialError:
|
|
84
87
|
raise HTTPException(404, f"Invalid device serial format: {serial}.")
|
|
85
|
-
server.scenario_manager.set_device_scenario(serial, scenario)
|
|
86
|
-
await persist_fn()
|
|
87
88
|
return ScenarioResponse(scope="device", identifier=serial, scenario=scenario)
|
|
88
89
|
|
|
89
90
|
@router.delete(
|
|
@@ -94,12 +95,13 @@ def _add_device_endpoints(router: APIRouter, server: EmulatedLifxServer, persist
|
|
|
94
95
|
responses={404: {"description": "Device scenario not found"}},
|
|
95
96
|
)
|
|
96
97
|
async def clear_device_scenario(serial: str):
|
|
97
|
-
|
|
98
|
+
try:
|
|
99
|
+
await service.delete_scope_scenario("device", serial)
|
|
100
|
+
except ScenarioNotFoundError:
|
|
98
101
|
raise HTTPException(404, f"No scenario set for device {serial}")
|
|
99
|
-
await persist_fn()
|
|
100
102
|
|
|
101
103
|
|
|
102
|
-
def _add_type_endpoints(router: APIRouter,
|
|
104
|
+
def _add_type_endpoints(router: APIRouter, service: ScenarioService):
|
|
103
105
|
"""Add type-specific scenario endpoints to router."""
|
|
104
106
|
|
|
105
107
|
@router.get(
|
|
@@ -110,8 +112,9 @@ def _add_type_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_f
|
|
|
110
112
|
responses={404: {"description": "Type scenario not set"}},
|
|
111
113
|
)
|
|
112
114
|
async def get_type_scenario(device_type: str):
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
try:
|
|
116
|
+
config = service.get_scope_scenario("type", device_type)
|
|
117
|
+
except ScenarioNotFoundError:
|
|
115
118
|
raise HTTPException(404, f"No scenario set for type {device_type}")
|
|
116
119
|
return ScenarioResponse(scope="type", identifier=device_type, scenario=config)
|
|
117
120
|
|
|
@@ -122,8 +125,7 @@ def _add_type_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_f
|
|
|
122
125
|
description="Sets the scenario for all devices of a specific type.",
|
|
123
126
|
)
|
|
124
127
|
async def set_type_scenario(device_type: str, scenario: ScenarioConfig):
|
|
125
|
-
|
|
126
|
-
await persist_fn()
|
|
128
|
+
await service.set_scope_scenario("type", device_type, scenario)
|
|
127
129
|
return ScenarioResponse(scope="type", identifier=device_type, scenario=scenario)
|
|
128
130
|
|
|
129
131
|
@router.delete(
|
|
@@ -134,12 +136,13 @@ def _add_type_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_f
|
|
|
134
136
|
responses={404: {"description": "Type scenario not found"}},
|
|
135
137
|
)
|
|
136
138
|
async def clear_type_scenario(device_type: str):
|
|
137
|
-
|
|
139
|
+
try:
|
|
140
|
+
await service.delete_scope_scenario("type", device_type)
|
|
141
|
+
except ScenarioNotFoundError:
|
|
138
142
|
raise HTTPException(404, f"No scenario set for type {device_type}")
|
|
139
|
-
await persist_fn()
|
|
140
143
|
|
|
141
144
|
|
|
142
|
-
def _add_location_endpoints(router: APIRouter,
|
|
145
|
+
def _add_location_endpoints(router: APIRouter, service: ScenarioService):
|
|
143
146
|
"""Add location-based scenario endpoints to router."""
|
|
144
147
|
|
|
145
148
|
@router.get(
|
|
@@ -150,8 +153,9 @@ def _add_location_endpoints(router: APIRouter, server: EmulatedLifxServer, persi
|
|
|
150
153
|
responses={404: {"description": "Location scenario not set"}},
|
|
151
154
|
)
|
|
152
155
|
async def get_location_scenario(location: str):
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
try:
|
|
157
|
+
config = service.get_scope_scenario("location", location)
|
|
158
|
+
except ScenarioNotFoundError:
|
|
155
159
|
raise HTTPException(404, f"No scenario set for location {location}")
|
|
156
160
|
return ScenarioResponse(scope="location", identifier=location, scenario=config)
|
|
157
161
|
|
|
@@ -162,8 +166,7 @@ def _add_location_endpoints(router: APIRouter, server: EmulatedLifxServer, persi
|
|
|
162
166
|
description="Sets the scenario for all devices in a location.",
|
|
163
167
|
)
|
|
164
168
|
async def set_location_scenario(location: str, scenario: ScenarioConfig):
|
|
165
|
-
|
|
166
|
-
await persist_fn()
|
|
169
|
+
await service.set_scope_scenario("location", location, scenario)
|
|
167
170
|
return ScenarioResponse(
|
|
168
171
|
scope="location", identifier=location, scenario=scenario
|
|
169
172
|
)
|
|
@@ -176,12 +179,13 @@ def _add_location_endpoints(router: APIRouter, server: EmulatedLifxServer, persi
|
|
|
176
179
|
responses={404: {"description": "Location scenario not found"}},
|
|
177
180
|
)
|
|
178
181
|
async def clear_location_scenario(location: str):
|
|
179
|
-
|
|
182
|
+
try:
|
|
183
|
+
await service.delete_scope_scenario("location", location)
|
|
184
|
+
except ScenarioNotFoundError:
|
|
180
185
|
raise HTTPException(404, f"No scenario set for location {location}")
|
|
181
|
-
await persist_fn()
|
|
182
186
|
|
|
183
187
|
|
|
184
|
-
def _add_group_endpoints(router: APIRouter,
|
|
188
|
+
def _add_group_endpoints(router: APIRouter, service: ScenarioService):
|
|
185
189
|
"""Add group-based scenario endpoints to router."""
|
|
186
190
|
|
|
187
191
|
@router.get(
|
|
@@ -192,8 +196,9 @@ def _add_group_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_
|
|
|
192
196
|
responses={404: {"description": "Group scenario not set"}},
|
|
193
197
|
)
|
|
194
198
|
async def get_group_scenario(group: str):
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
try:
|
|
200
|
+
config = service.get_scope_scenario("group", group)
|
|
201
|
+
except ScenarioNotFoundError:
|
|
197
202
|
raise HTTPException(404, f"No scenario set for group {group}")
|
|
198
203
|
return ScenarioResponse(scope="group", identifier=group, scenario=config)
|
|
199
204
|
|
|
@@ -204,8 +209,7 @@ def _add_group_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_
|
|
|
204
209
|
description="Sets the scenario for all devices in a group.",
|
|
205
210
|
)
|
|
206
211
|
async def set_group_scenario(group: str, scenario: ScenarioConfig):
|
|
207
|
-
|
|
208
|
-
await persist_fn()
|
|
212
|
+
await service.set_scope_scenario("group", group, scenario)
|
|
209
213
|
return ScenarioResponse(scope="group", identifier=group, scenario=scenario)
|
|
210
214
|
|
|
211
215
|
@router.delete(
|
|
@@ -216,32 +220,31 @@ def _add_group_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_
|
|
|
216
220
|
responses={404: {"description": "Group scenario not found"}},
|
|
217
221
|
)
|
|
218
222
|
async def clear_group_scenario(group: str):
|
|
219
|
-
|
|
223
|
+
try:
|
|
224
|
+
await service.delete_scope_scenario("group", group)
|
|
225
|
+
except ScenarioNotFoundError:
|
|
220
226
|
raise HTTPException(404, f"No scenario set for group {group}")
|
|
221
|
-
await persist_fn()
|
|
222
227
|
|
|
223
228
|
|
|
224
|
-
def create_scenarios_router(
|
|
229
|
+
def create_scenarios_router(
|
|
230
|
+
server: EmulatedLifxServer, ws_manager: WebSocketManager | None = None
|
|
231
|
+
) -> APIRouter:
|
|
225
232
|
"""Create scenarios router with server dependency.
|
|
226
233
|
|
|
227
234
|
Args:
|
|
228
235
|
server: The LIFX emulator server instance
|
|
236
|
+
ws_manager: Optional WebSocket manager for real-time updates
|
|
229
237
|
|
|
230
238
|
Returns:
|
|
231
239
|
Configured APIRouter for scenario endpoints
|
|
232
240
|
"""
|
|
233
241
|
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
|
242
|
+
service = ScenarioService(server, ws_manager)
|
|
234
243
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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)
|
|
244
|
+
_add_global_endpoints(router, service)
|
|
245
|
+
_add_device_endpoints(router, service)
|
|
246
|
+
_add_type_endpoints(router, service)
|
|
247
|
+
_add_location_endpoints(router, service)
|
|
248
|
+
_add_group_endpoints(router, service)
|
|
246
249
|
|
|
247
250
|
return router
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""WebSocket endpoint for real-time updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from lifx_emulator_app.api.services.websocket_manager import WebSocketManager
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_websocket_router(ws_manager: WebSocketManager) -> APIRouter:
|
|
17
|
+
"""Create WebSocket router with manager dependency.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
ws_manager: The WebSocket manager instance
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Configured APIRouter for WebSocket endpoint
|
|
24
|
+
"""
|
|
25
|
+
router = APIRouter(tags=["websocket"])
|
|
26
|
+
|
|
27
|
+
@router.websocket("/ws")
|
|
28
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
29
|
+
"""WebSocket endpoint for real-time updates.
|
|
30
|
+
|
|
31
|
+
Clients connect and send messages to subscribe to topics:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{"type": "subscribe", "topics": ["stats", "devices", "activity", "scenarios"]}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Available topics:
|
|
38
|
+
- stats: Server statistics (pushed every second)
|
|
39
|
+
- devices: Device add/remove/update events
|
|
40
|
+
- activity: Packet activity events
|
|
41
|
+
- scenarios: Scenario configuration changes
|
|
42
|
+
|
|
43
|
+
To request a full state sync:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{"type": "sync"}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Server pushes messages in the format:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{"type": "<message_type>", "data": {...}}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Where message_type is one of: stats, device_added, device_removed,
|
|
56
|
+
device_updated, activity, scenario_changed.
|
|
57
|
+
"""
|
|
58
|
+
await ws_manager.connect(websocket)
|
|
59
|
+
try:
|
|
60
|
+
while True:
|
|
61
|
+
data = await websocket.receive_json()
|
|
62
|
+
await ws_manager.handle_message(websocket, data)
|
|
63
|
+
except WebSocketDisconnect:
|
|
64
|
+
logger.debug("WebSocket client disconnected normally")
|
|
65
|
+
except Exception:
|
|
66
|
+
logger.exception("WebSocket error")
|
|
67
|
+
finally:
|
|
68
|
+
await ws_manager.disconnect(websocket)
|
|
69
|
+
|
|
70
|
+
return router
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
"""Business logic services for API endpoints."""
|
|
2
2
|
|
|
3
3
|
from lifx_emulator_app.api.services.device_service import DeviceService
|
|
4
|
+
from lifx_emulator_app.api.services.event_bridge import (
|
|
5
|
+
StatsBroadcaster,
|
|
6
|
+
WebSocketActivityObserver,
|
|
7
|
+
wire_device_events,
|
|
8
|
+
)
|
|
9
|
+
from lifx_emulator_app.api.services.scenario_service import ScenarioService
|
|
10
|
+
from lifx_emulator_app.api.services.websocket_manager import (
|
|
11
|
+
MessageType,
|
|
12
|
+
Topic,
|
|
13
|
+
WebSocketManager,
|
|
14
|
+
)
|
|
4
15
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DeviceService",
|
|
18
|
+
"MessageType",
|
|
19
|
+
"ScenarioService",
|
|
20
|
+
"StatsBroadcaster",
|
|
21
|
+
"Topic",
|
|
22
|
+
"WebSocketManager",
|
|
23
|
+
"WebSocketActivityObserver",
|
|
24
|
+
"wire_device_events",
|
|
25
|
+
]
|
|
@@ -11,12 +11,20 @@ import logging
|
|
|
11
11
|
from typing import TYPE_CHECKING
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
|
+
from lifx_emulator.devices import EmulatedLifxDevice
|
|
14
15
|
from lifx_emulator.server import EmulatedLifxServer
|
|
15
16
|
|
|
16
17
|
from lifx_emulator.factories import create_device
|
|
18
|
+
from lifx_emulator.protocol.protocol_types import LightHsbk
|
|
17
19
|
|
|
18
20
|
from lifx_emulator_app.api.mappers import DeviceMapper
|
|
19
|
-
from lifx_emulator_app.api.models import
|
|
21
|
+
from lifx_emulator_app.api.models import (
|
|
22
|
+
ColorHsbk,
|
|
23
|
+
DeviceCreateRequest,
|
|
24
|
+
DeviceInfo,
|
|
25
|
+
DeviceStateUpdate,
|
|
26
|
+
TileColorUpdate,
|
|
27
|
+
)
|
|
20
28
|
|
|
21
29
|
logger = logging.getLogger(__name__)
|
|
22
30
|
|
|
@@ -43,6 +51,12 @@ class DeviceCreationError(Exception):
|
|
|
43
51
|
pass
|
|
44
52
|
|
|
45
53
|
|
|
54
|
+
class DeviceStateUpdateError(Exception):
|
|
55
|
+
"""Raised when a device state update fails due to capability mismatch."""
|
|
56
|
+
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
46
60
|
class DeviceService:
|
|
47
61
|
"""Service for managing emulated LIFX devices.
|
|
48
62
|
|
|
@@ -197,3 +211,176 @@ class DeviceService:
|
|
|
197
211
|
count = self.server.remove_all_devices(delete_storage=delete_storage)
|
|
198
212
|
logger.info("Cleared %d devices (delete_storage=%s)", count, delete_storage)
|
|
199
213
|
return count
|
|
214
|
+
|
|
215
|
+
def update_device_state(self, serial: str, update: DeviceStateUpdate) -> DeviceInfo:
|
|
216
|
+
"""Update the state of an existing device.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
serial: The device serial number
|
|
220
|
+
update: The state update to apply
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Updated DeviceInfo
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
DeviceNotFoundError: If no device with the given serial exists
|
|
227
|
+
DeviceStateUpdateError: If update is invalid for the device's capabilities
|
|
228
|
+
"""
|
|
229
|
+
device = self.server.get_device(serial)
|
|
230
|
+
if not device:
|
|
231
|
+
raise DeviceNotFoundError(serial)
|
|
232
|
+
|
|
233
|
+
if update.power_level is not None:
|
|
234
|
+
device.state.power_level = update.power_level
|
|
235
|
+
|
|
236
|
+
if update.color is not None:
|
|
237
|
+
self._apply_color(device, update.color)
|
|
238
|
+
|
|
239
|
+
if update.zone_colors is not None:
|
|
240
|
+
self._apply_zone_colors(device, serial, update.zone_colors)
|
|
241
|
+
|
|
242
|
+
if update.tile_colors is not None:
|
|
243
|
+
self._apply_tile_colors(device, serial, update.tile_colors)
|
|
244
|
+
|
|
245
|
+
return DeviceMapper.to_device_info(device)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _to_hsbk(c: ColorHsbk) -> LightHsbk:
|
|
249
|
+
return LightHsbk(
|
|
250
|
+
hue=c.hue,
|
|
251
|
+
saturation=c.saturation,
|
|
252
|
+
brightness=c.brightness,
|
|
253
|
+
kelvin=c.kelvin,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _fill_hsbk(hsbk: LightHsbk, count: int) -> list[LightHsbk]:
|
|
258
|
+
return [
|
|
259
|
+
LightHsbk(
|
|
260
|
+
hue=hsbk.hue,
|
|
261
|
+
saturation=hsbk.saturation,
|
|
262
|
+
brightness=hsbk.brightness,
|
|
263
|
+
kelvin=hsbk.kelvin,
|
|
264
|
+
)
|
|
265
|
+
for _ in range(count)
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _pad_and_truncate(colors: list[LightHsbk], target: int) -> list[LightHsbk]:
|
|
270
|
+
if len(colors) < target and len(colors) > 0:
|
|
271
|
+
last = colors[-1]
|
|
272
|
+
colors.extend(
|
|
273
|
+
LightHsbk(
|
|
274
|
+
hue=last.hue,
|
|
275
|
+
saturation=last.saturation,
|
|
276
|
+
brightness=last.brightness,
|
|
277
|
+
kelvin=last.kelvin,
|
|
278
|
+
)
|
|
279
|
+
for _ in range(target - len(colors))
|
|
280
|
+
)
|
|
281
|
+
return colors[:target]
|
|
282
|
+
|
|
283
|
+
def _apply_color(self, device: EmulatedLifxDevice, color: ColorHsbk) -> None:
|
|
284
|
+
hsbk = self._to_hsbk(color)
|
|
285
|
+
device.state.color = hsbk
|
|
286
|
+
|
|
287
|
+
if device.state.has_multizone and device.state.multizone is not None:
|
|
288
|
+
zone_count = device.state.multizone.zone_count
|
|
289
|
+
device.state.multizone.zone_colors = self._fill_hsbk(hsbk, zone_count)
|
|
290
|
+
|
|
291
|
+
if device.state.has_matrix and device.state.matrix is not None:
|
|
292
|
+
for tile in device.state.matrix.tile_devices:
|
|
293
|
+
width = tile.get("width", 8)
|
|
294
|
+
height = tile.get("height", 8)
|
|
295
|
+
tile["colors"] = self._fill_hsbk(hsbk, width * height)
|
|
296
|
+
|
|
297
|
+
def _apply_zone_colors(
|
|
298
|
+
self,
|
|
299
|
+
device: EmulatedLifxDevice,
|
|
300
|
+
serial: str,
|
|
301
|
+
zone_colors: list[ColorHsbk],
|
|
302
|
+
) -> None:
|
|
303
|
+
if not device.state.has_multizone or device.state.multizone is None:
|
|
304
|
+
raise DeviceStateUpdateError(f"Device {serial} does not support multizone")
|
|
305
|
+
zone_count = device.state.multizone.zone_count
|
|
306
|
+
colors = [self._to_hsbk(c) for c in zone_colors]
|
|
307
|
+
device.state.multizone.zone_colors = self._pad_and_truncate(colors, zone_count)
|
|
308
|
+
|
|
309
|
+
def _apply_tile_colors(
|
|
310
|
+
self,
|
|
311
|
+
device: EmulatedLifxDevice,
|
|
312
|
+
serial: str,
|
|
313
|
+
tile_colors: list[TileColorUpdate],
|
|
314
|
+
) -> None:
|
|
315
|
+
if not device.state.has_matrix or device.state.matrix is None:
|
|
316
|
+
raise DeviceStateUpdateError(f"Device {serial} does not support matrix")
|
|
317
|
+
for tile_update in tile_colors:
|
|
318
|
+
idx = tile_update.tile_index
|
|
319
|
+
if idx >= len(device.state.matrix.tile_devices):
|
|
320
|
+
raise DeviceStateUpdateError(
|
|
321
|
+
f"Tile index {idx} out of range "
|
|
322
|
+
f"(device has {len(device.state.matrix.tile_devices)} tiles)"
|
|
323
|
+
)
|
|
324
|
+
tile = device.state.matrix.tile_devices[idx]
|
|
325
|
+
width = tile.get("width", 8)
|
|
326
|
+
height = tile.get("height", 8)
|
|
327
|
+
colors = [self._to_hsbk(c) for c in tile_update.colors]
|
|
328
|
+
tile["colors"] = self._pad_and_truncate(colors, width * height)
|
|
329
|
+
|
|
330
|
+
def create_devices_bulk(
|
|
331
|
+
self, requests: list[DeviceCreateRequest]
|
|
332
|
+
) -> list[DeviceInfo]:
|
|
333
|
+
"""Create multiple devices at once.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
requests: List of device creation requests
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
List of DeviceInfo objects for the newly created devices
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
DeviceAlreadyExistsError: If any serial conflicts with existing or batch
|
|
343
|
+
DeviceCreationError: If any device creation fails
|
|
344
|
+
"""
|
|
345
|
+
# Validate no duplicate serials within the batch
|
|
346
|
+
serials_in_batch: list[str] = []
|
|
347
|
+
for req in requests:
|
|
348
|
+
if req.serial is not None:
|
|
349
|
+
if req.serial in serials_in_batch:
|
|
350
|
+
raise DeviceAlreadyExistsError(req.serial)
|
|
351
|
+
# Check against existing devices
|
|
352
|
+
if self.server.get_device(req.serial):
|
|
353
|
+
raise DeviceAlreadyExistsError(req.serial)
|
|
354
|
+
serials_in_batch.append(req.serial)
|
|
355
|
+
|
|
356
|
+
created: list[DeviceInfo] = []
|
|
357
|
+
created_serials: list[str] = []
|
|
358
|
+
try:
|
|
359
|
+
for req in requests:
|
|
360
|
+
info = self.create_device(req)
|
|
361
|
+
created.append(info)
|
|
362
|
+
created_serials.append(info.serial)
|
|
363
|
+
except Exception:
|
|
364
|
+
# Roll back: remove already-added devices
|
|
365
|
+
for serial in created_serials:
|
|
366
|
+
self.server.remove_device(serial)
|
|
367
|
+
raise
|
|
368
|
+
|
|
369
|
+
return created
|
|
370
|
+
|
|
371
|
+
def list_devices_paginated(
|
|
372
|
+
self, offset: int, limit: int
|
|
373
|
+
) -> tuple[list[DeviceInfo], int]:
|
|
374
|
+
"""Get a paginated list of devices.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
offset: Number of devices to skip
|
|
378
|
+
limit: Maximum number of devices to return
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Tuple of (device info list, total device count)
|
|
382
|
+
"""
|
|
383
|
+
devices = self.server.get_all_devices()
|
|
384
|
+
total = len(devices)
|
|
385
|
+
sliced = devices[offset : offset + limit]
|
|
386
|
+
return DeviceMapper.to_device_info_list(sliced), total
|