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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. lifx_emulator/__init__.py +1 -1
  2. lifx_emulator/__main__.py +26 -51
  3. lifx_emulator/api/__init__.py +18 -0
  4. lifx_emulator/api/app.py +154 -0
  5. lifx_emulator/api/mappers/__init__.py +5 -0
  6. lifx_emulator/api/mappers/device_mapper.py +114 -0
  7. lifx_emulator/api/models.py +133 -0
  8. lifx_emulator/api/routers/__init__.py +11 -0
  9. lifx_emulator/api/routers/devices.py +130 -0
  10. lifx_emulator/api/routers/monitoring.py +52 -0
  11. lifx_emulator/api/routers/scenarios.py +247 -0
  12. lifx_emulator/api/services/__init__.py +8 -0
  13. lifx_emulator/api/services/device_service.py +198 -0
  14. lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
  15. lifx_emulator/devices/__init__.py +37 -0
  16. lifx_emulator/devices/device.py +333 -0
  17. lifx_emulator/devices/manager.py +256 -0
  18. lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
  19. lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
  20. lifx_emulator/devices/states.py +346 -0
  21. lifx_emulator/factories/__init__.py +37 -0
  22. lifx_emulator/factories/builder.py +371 -0
  23. lifx_emulator/factories/default_config.py +158 -0
  24. lifx_emulator/factories/factory.py +221 -0
  25. lifx_emulator/factories/firmware_config.py +59 -0
  26. lifx_emulator/factories/serial_generator.py +82 -0
  27. lifx_emulator/handlers/base.py +1 -1
  28. lifx_emulator/handlers/device_handlers.py +10 -28
  29. lifx_emulator/handlers/light_handlers.py +5 -9
  30. lifx_emulator/handlers/multizone_handlers.py +1 -1
  31. lifx_emulator/handlers/tile_handlers.py +31 -11
  32. lifx_emulator/products/generator.py +389 -170
  33. lifx_emulator/products/registry.py +52 -40
  34. lifx_emulator/products/specs.py +12 -13
  35. lifx_emulator/protocol/base.py +175 -63
  36. lifx_emulator/protocol/generator.py +18 -5
  37. lifx_emulator/protocol/packets.py +7 -7
  38. lifx_emulator/protocol/protocol_types.py +35 -62
  39. lifx_emulator/repositories/__init__.py +22 -0
  40. lifx_emulator/repositories/device_repository.py +155 -0
  41. lifx_emulator/repositories/storage_backend.py +107 -0
  42. lifx_emulator/scenarios/__init__.py +22 -0
  43. lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
  44. lifx_emulator/scenarios/models.py +112 -0
  45. lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
  46. lifx_emulator/server.py +42 -66
  47. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/METADATA +1 -1
  48. lifx_emulator-2.1.0.dist-info/RECORD +62 -0
  49. lifx_emulator/device.py +0 -750
  50. lifx_emulator/device_states.py +0 -114
  51. lifx_emulator/factories.py +0 -380
  52. lifx_emulator/storage_protocol.py +0 -100
  53. lifx_emulator-1.0.2.dist-info/RECORD +0 -40
  54. /lifx_emulator/{observers.py → devices/observers.py} +0 -0
  55. /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
  56. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/WHEEL +0 -0
  57. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/entry_points.txt +0 -0
  58. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -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