lifx-emulator 4.0.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.
Files changed (42) hide show
  1. {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/METADATA +2 -1
  2. lifx_emulator-4.2.0.dist-info/RECORD +43 -0
  3. lifx_emulator_app/__main__.py +35 -7
  4. lifx_emulator_app/api/__init__.py +0 -4
  5. lifx_emulator_app/api/app.py +122 -16
  6. lifx_emulator_app/api/models.py +32 -1
  7. lifx_emulator_app/api/routers/__init__.py +5 -1
  8. lifx_emulator_app/api/routers/devices.py +64 -10
  9. lifx_emulator_app/api/routers/products.py +42 -0
  10. lifx_emulator_app/api/routers/scenarios.py +55 -52
  11. lifx_emulator_app/api/routers/websocket.py +70 -0
  12. lifx_emulator_app/api/services/__init__.py +21 -4
  13. lifx_emulator_app/api/services/device_service.py +188 -1
  14. lifx_emulator_app/api/services/event_bridge.py +234 -0
  15. lifx_emulator_app/api/services/scenario_service.py +153 -0
  16. lifx_emulator_app/api/services/websocket_manager.py +326 -0
  17. lifx_emulator_app/api/static/_app/env.js +1 -0
  18. lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css +1 -0
  19. lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css +1 -0
  20. lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js +1 -0
  21. lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js +1 -0
  22. lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js +2 -0
  23. lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js +1 -0
  24. lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js +1 -0
  25. lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js +1 -0
  26. lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js +1 -0
  27. lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js +1 -0
  28. lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js +1 -0
  29. lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js +2 -0
  30. lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js +1 -0
  31. lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js +1 -0
  32. lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js +1 -0
  33. lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js +6 -0
  34. lifx_emulator_app/api/static/_app/version.json +1 -0
  35. lifx_emulator_app/api/static/index.html +38 -0
  36. lifx_emulator_app/api/static/robots.txt +3 -0
  37. lifx_emulator_app/config.py +2 -0
  38. lifx_emulator-4.0.0.dist-info/RECORD +0 -20
  39. lifx_emulator_app/api/static/dashboard.js +0 -588
  40. lifx_emulator_app/api/templates/dashboard.html +0 -357
  41. {lifx_emulator-4.0.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
  42. {lifx_emulator-4.0.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.models import ScenarioConfig, ScenarioResponse
13
-
12
+ from lifx_emulator_app.api.services.websocket_manager import WebSocketManager
14
13
 
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)
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, server: EmulatedLifxServer, persist_fn):
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 = server.scenario_manager.get_global_scenario()
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
- server.scenario_manager.set_global_scenario(scenario)
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
- server.scenario_manager.clear_global_scenario()
56
- await persist_fn()
56
+ await service.clear_global_scenario()
57
57
 
58
58
 
59
- def _add_device_endpoints(router: APIRouter, server: EmulatedLifxServer, persist_fn):
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
- config = server.scenario_manager.get_device_scenario(serial)
71
- if config is None:
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
- if not _validate_device_serial(serial):
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
- if not server.scenario_manager.delete_device_scenario(serial):
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, server: EmulatedLifxServer, persist_fn):
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
- config = server.scenario_manager.get_type_scenario(device_type)
114
- if config is None:
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
- server.scenario_manager.set_type_scenario(device_type, scenario)
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
- if not server.scenario_manager.delete_type_scenario(device_type):
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, server: EmulatedLifxServer, persist_fn):
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
- config = server.scenario_manager.get_location_scenario(location)
154
- if config is None:
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
- server.scenario_manager.set_location_scenario(location, scenario)
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
- if not server.scenario_manager.delete_location_scenario(location):
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, server: EmulatedLifxServer, persist_fn):
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
- config = server.scenario_manager.get_group_scenario(group)
196
- if config is None:
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
- server.scenario_manager.set_group_scenario(group, scenario)
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
- if not server.scenario_manager.delete_group_scenario(group):
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(server: EmulatedLifxServer) -> APIRouter:
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
- 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)
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
- # TODO: Create ScenarioService (Phase 1.1b completion)
6
- # from lifx_emulator_app.api.services.scenario_service import ScenarioService
7
-
8
- __all__ = ["DeviceService"]
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 DeviceCreateRequest, DeviceInfo
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