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,37 @@
1
+ """Device management module for LIFX emulator.
2
+
3
+ This module contains all device-related functionality including:
4
+ - Device core (EmulatedLifxDevice)
5
+ - Device manager (DeviceManager, IDeviceManager)
6
+ - Device states (DeviceState and related dataclasses)
7
+ - Device persistence (async file storage)
8
+ - State restoration and serialization
9
+ - Device state observers (ActivityObserver, ActivityLogger, PacketEvent, NullObserver)
10
+ """
11
+
12
+ from lifx_emulator.devices.device import EmulatedLifxDevice
13
+ from lifx_emulator.devices.manager import DeviceManager, IDeviceManager
14
+ from lifx_emulator.devices.observers import (
15
+ ActivityLogger,
16
+ ActivityObserver,
17
+ NullObserver,
18
+ PacketEvent,
19
+ )
20
+ from lifx_emulator.devices.persistence import (
21
+ DEFAULT_STORAGE_DIR,
22
+ DevicePersistenceAsyncFile,
23
+ )
24
+ from lifx_emulator.devices.states import DeviceState
25
+
26
+ __all__ = [
27
+ "EmulatedLifxDevice",
28
+ "DeviceManager",
29
+ "IDeviceManager",
30
+ "DeviceState",
31
+ "DevicePersistenceAsyncFile",
32
+ "DEFAULT_STORAGE_DIR",
33
+ "ActivityObserver",
34
+ "ActivityLogger",
35
+ "PacketEvent",
36
+ "NullObserver",
37
+ ]
@@ -0,0 +1,333 @@
1
+ """Device state and emulated device implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import copy
7
+ import logging
8
+ import time
9
+ from typing import Any
10
+
11
+ from lifx_emulator.constants import LIFX_HEADER_SIZE
12
+ from lifx_emulator.devices.states import DeviceState
13
+ from lifx_emulator.handlers import HandlerRegistry, create_default_registry
14
+ from lifx_emulator.protocol.header import LifxHeader
15
+ from lifx_emulator.protocol.packets import (
16
+ Device,
17
+ )
18
+ from lifx_emulator.protocol.protocol_types import LightHsbk
19
+ from lifx_emulator.scenarios import (
20
+ HierarchicalScenarioManager,
21
+ ScenarioConfig,
22
+ get_device_type,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Forward declaration for type hinting
28
+ TYPE_CHECKING = False
29
+ if TYPE_CHECKING:
30
+ from lifx_emulator.devices.persistence import DevicePersistenceAsyncFile
31
+
32
+
33
+ class EmulatedLifxDevice:
34
+ """Emulated LIFX device with configurable scenarios and state management."""
35
+
36
+ def __init__(
37
+ self,
38
+ device_state: DeviceState,
39
+ storage: DevicePersistenceAsyncFile | None = None,
40
+ handler_registry: HandlerRegistry | None = None,
41
+ scenario_manager: HierarchicalScenarioManager | None = None,
42
+ ):
43
+ self.state = device_state
44
+ # Use provided scenario manager or create a default empty one
45
+ if scenario_manager is not None:
46
+ self.scenario_manager = scenario_manager
47
+ else:
48
+ self.scenario_manager = HierarchicalScenarioManager()
49
+ self.start_time = time.time()
50
+ self.storage = storage
51
+
52
+ # Scenario caching for performance (HierarchicalScenarioManager only)
53
+ self._cached_scenario: ScenarioConfig | None = None
54
+
55
+ # Track background save tasks to prevent garbage collection
56
+ self.background_save_tasks: set[asyncio.Task] = set()
57
+
58
+ # Use provided registry or create default one
59
+ self.handlers = handler_registry or create_default_registry()
60
+
61
+ # Pre-allocate response header template for performance (10-15% gain)
62
+ # This avoids creating a new LifxHeader object for every response
63
+ self._response_header_template = LifxHeader(
64
+ source=0,
65
+ target=self.state.get_target_bytes(),
66
+ sequence=0,
67
+ tagged=False,
68
+ pkt_type=0,
69
+ size=0,
70
+ )
71
+
72
+ # Initialize multizone colors if needed
73
+ # Note: State restoration is handled by StateRestorer in factories
74
+ if self.state.has_multizone and self.state.zone_count > 0:
75
+ if not self.state.zone_colors:
76
+ # Initialize with rainbow pattern using list comprehension
77
+ # (performance optimization)
78
+ self.state.zone_colors = [
79
+ LightHsbk(
80
+ hue=int((i / self.state.zone_count) * 65535),
81
+ saturation=65535,
82
+ brightness=32768,
83
+ kelvin=3500,
84
+ )
85
+ for i in range(self.state.zone_count)
86
+ ]
87
+
88
+ # Initialize tile state if needed
89
+ # Note: Saved tile data is restored by StateRestorer in factories
90
+ if self.state.has_matrix and self.state.tile_count > 0:
91
+ if not self.state.tile_devices:
92
+ for i in range(self.state.tile_count):
93
+ pixels = self.state.tile_width * self.state.tile_height
94
+ tile_colors = [
95
+ LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=3500)
96
+ for _ in range(pixels)
97
+ ]
98
+
99
+ self.state.tile_devices.append(
100
+ {
101
+ "accel_meas_x": 0,
102
+ "accel_meas_y": 0,
103
+ "accel_meas_z": 0,
104
+ "user_x": float(i * self.state.tile_width),
105
+ "user_y": 0.0,
106
+ "width": self.state.tile_width,
107
+ "height": self.state.tile_height,
108
+ "device_version_vendor": 1,
109
+ "device_version_product": self.state.product,
110
+ "firmware_build": int(time.time()),
111
+ "firmware_version_minor": 70,
112
+ "firmware_version_major": 3,
113
+ "colors": tile_colors,
114
+ }
115
+ )
116
+
117
+ # Save initial state if persistence is enabled
118
+ # This ensures newly created devices are immediately persisted
119
+ if self.storage:
120
+ self._save_state()
121
+
122
+ def get_uptime_ns(self) -> int:
123
+ """Calculate current uptime in nanoseconds"""
124
+ return int((time.time() - self.start_time) * 1e9)
125
+
126
+ def _save_state(self) -> None:
127
+ """Save device state asynchronously (non-blocking).
128
+
129
+ Creates a background task to save state without blocking the event loop.
130
+ The task is tracked to prevent garbage collection.
131
+
132
+ Note: Only DevicePersistenceAsyncFile is supported in production. For testing,
133
+ you can pass None to disable persistence.
134
+ """
135
+ if not self.storage:
136
+ return
137
+
138
+ try:
139
+ loop = asyncio.get_running_loop()
140
+ task = loop.create_task(self.storage.save_device_state(self.state))
141
+ self._track_save_task(task)
142
+ except RuntimeError:
143
+ # No event loop (shouldn't happen in normal operation)
144
+ logger.error("Cannot save state for %s: no event loop", self.state.serial)
145
+
146
+ def _track_save_task(self, task: asyncio.Task) -> None:
147
+ """Track background save task to prevent garbage collection.
148
+
149
+ Args:
150
+ task: Save task to track
151
+ """
152
+ self.background_save_tasks.add(task)
153
+ task.add_done_callback(self.background_save_tasks.discard)
154
+
155
+ def _get_resolved_scenario(self) -> ScenarioConfig:
156
+ """Get resolved scenario configuration with caching.
157
+
158
+ Resolves scenario from all applicable scopes and caches the result
159
+ for performance.
160
+
161
+ Returns:
162
+ ScenarioConfig with resolved settings
163
+ """
164
+ if self._cached_scenario is not None:
165
+ return self._cached_scenario
166
+
167
+ # Resolve scenario with hierarchical scoping
168
+ self._cached_scenario = self.scenario_manager.get_scenario_for_device(
169
+ serial=self.state.serial,
170
+ device_type=get_device_type(self),
171
+ location=self.state.location_label,
172
+ group=self.state.group_label,
173
+ )
174
+ return self._cached_scenario
175
+
176
+ def invalidate_scenario_cache(self) -> None:
177
+ """Invalidate cached scenario configuration.
178
+
179
+ Call this when scenarios are updated at runtime to force
180
+ recalculation on the next packet.
181
+ """
182
+ self._cached_scenario = None
183
+
184
+ def _create_response_header(
185
+ self, source: int, sequence: int, pkt_type: int, payload_size: int
186
+ ) -> LifxHeader:
187
+ """Create response header using pre-allocated template (performance).
188
+
189
+ This method uses a pre-allocated template and creates a shallow copy,
190
+ then updates the fields. This avoids full __init__ and __post_init__
191
+ overhead while ensuring each response gets its own header object,
192
+ providing ~10% improvement in response generation.
193
+
194
+ Args:
195
+ source: Source identifier from request
196
+ sequence: Sequence number from request
197
+ pkt_type: Packet type for response
198
+ payload_size: Size of packed payload in bytes
199
+
200
+ Returns:
201
+ Configured LifxHeader ready to use
202
+ """
203
+ # Shallow copy of template is faster than full construction with validation
204
+ header = copy.copy(self._response_header_template)
205
+ # Update fields for this specific response
206
+ header.source = source
207
+ header.sequence = sequence
208
+ header.pkt_type = pkt_type
209
+ header.size = LIFX_HEADER_SIZE + payload_size
210
+ return header
211
+
212
+ def process_packet(
213
+ self, header: LifxHeader, packet: Any | None
214
+ ) -> list[tuple[LifxHeader, Any]]:
215
+ """Process incoming packet and return response packets"""
216
+ responses = []
217
+
218
+ # Get resolved scenario configuration (cached for performance)
219
+ scenario = self._get_resolved_scenario()
220
+
221
+ # Check if packet should be dropped (with probabilistic drops)
222
+ if not self.scenario_manager.should_respond(header.pkt_type, scenario):
223
+ logger.info("Dropping packet type %s per scenario", header.pkt_type)
224
+ return responses
225
+
226
+ # Update uptime
227
+ self.state.uptime_ns = self.get_uptime_ns()
228
+
229
+ # Handle acknowledgment (packet type 45, no payload)
230
+ if header.ack_required:
231
+ ack_packet = Device.Acknowledgement()
232
+ ack_payload = ack_packet.pack()
233
+ ack_header = self._create_response_header(
234
+ header.source,
235
+ header.sequence,
236
+ ack_packet.PKT_TYPE,
237
+ len(ack_payload),
238
+ )
239
+ # Store header, packet, and pre-packed payload
240
+ # (consistent with response format)
241
+ responses.append((ack_header, ack_packet, ack_payload))
242
+
243
+ # Handle specific packet types - handlers always return list
244
+ response_packets = self._handle_packet_type(header, packet)
245
+ # Handlers now always return list (empty if no response)
246
+ for resp_packet in response_packets:
247
+ # Cache packed payload to avoid double packing (performance optimization)
248
+ resp_payload = resp_packet.pack()
249
+ resp_header = self._create_response_header(
250
+ header.source,
251
+ header.sequence,
252
+ resp_packet.PKT_TYPE,
253
+ len(resp_payload),
254
+ )
255
+ # Store both header and pre-packed payload for error scenario processing
256
+ responses.append((resp_header, resp_packet, resp_payload))
257
+
258
+ # Apply error scenarios to responses
259
+ modified_responses = []
260
+ for resp_header, resp_packet, resp_payload in responses:
261
+ # Check if we should send malformed packet (truncate payload)
262
+ if resp_header.pkt_type in scenario.malformed_packets:
263
+ # For malformed packets, truncate the pre-packed payload
264
+ truncated_len = len(resp_payload) // 2
265
+ resp_payload_modified = resp_payload[:truncated_len]
266
+ resp_header.size = LIFX_HEADER_SIZE + truncated_len + 10 # Wrong size
267
+ # Convert back to bytes for malformed case
268
+ modified_responses.append((resp_header, resp_payload_modified))
269
+ logger.info(
270
+ "Sending malformed packet type %s (truncated)", resp_header.pkt_type
271
+ )
272
+ continue
273
+
274
+ # Check if we should send invalid field values
275
+ if resp_header.pkt_type in scenario.invalid_field_values:
276
+ # Corrupt the pre-packed payload
277
+ resp_payload_modified = b"\xff" * len(resp_payload)
278
+ modified_responses.append((resp_header, resp_payload_modified))
279
+ pkt_type = resp_header.pkt_type
280
+ logger.info("Sending invalid field values for packet type %s", pkt_type)
281
+ continue
282
+
283
+ # Normal case: use original packet object (will be packed later by server)
284
+ modified_responses.append((resp_header, resp_packet))
285
+
286
+ return modified_responses
287
+
288
+ def _handle_packet_type(self, header: LifxHeader, packet: Any | None) -> list[Any]:
289
+ """Handle specific packet types using registered handlers.
290
+
291
+ Returns:
292
+ List of response packets (empty list if no response)
293
+ """
294
+ pkt_type = header.pkt_type
295
+
296
+ # Update uptime for this packet
297
+ self.state.uptime_ns = self.get_uptime_ns()
298
+
299
+ # Find handler for this packet type
300
+ handler = self.handlers.get_handler(pkt_type)
301
+
302
+ if handler:
303
+ # Delegate to handler (always returns list now)
304
+ response = handler.handle(self.state, packet, header.res_required)
305
+
306
+ # Save state if storage is enabled (for SET operations)
307
+ if packet and self.storage:
308
+ self._save_state()
309
+
310
+ return response
311
+ else:
312
+ # Unknown/unimplemented packet type
313
+ from lifx_emulator.protocol.packets import get_packet_class
314
+
315
+ packet_class = get_packet_class(pkt_type)
316
+ if packet_class:
317
+ logger.info(
318
+ "Device %s: Received %s (type %s) but no handler registered",
319
+ self.state.serial,
320
+ packet_class.__qualname__,
321
+ pkt_type,
322
+ )
323
+ else:
324
+ serial = self.state.serial
325
+ logger.warning(
326
+ "Device %s: Received unknown packet type %s", serial, pkt_type
327
+ )
328
+
329
+ # Check scenario for StateUnhandled response
330
+ scenario = self._get_resolved_scenario()
331
+ if scenario.send_unhandled:
332
+ return [Device.StateUnhandled(unhandled_type=pkt_type)]
333
+ return []
@@ -0,0 +1,256 @@
1
+ """Device management for LIFX emulator.
2
+
3
+ This module provides the DeviceManager class which handles device lifecycle
4
+ operations, packet routing, and device lookup. It follows the separation of
5
+ concerns principle by extracting domain logic from the network layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
12
+
13
+ if TYPE_CHECKING:
14
+ from lifx_emulator.devices.device import EmulatedLifxDevice
15
+ from lifx_emulator.protocol.header import LifxHeader
16
+ from lifx_emulator.repositories import IDeviceRepository
17
+ from lifx_emulator.scenarios import HierarchicalScenarioManager
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @runtime_checkable
23
+ class IDeviceManager(Protocol):
24
+ """Interface for device management operations."""
25
+
26
+ def add_device(
27
+ self,
28
+ device: EmulatedLifxDevice,
29
+ scenario_manager: HierarchicalScenarioManager | None = None,
30
+ ) -> bool:
31
+ """Add a device to the manager.
32
+
33
+ Args:
34
+ device: The device to add
35
+ scenario_manager: Optional scenario manager to share with the device
36
+
37
+ Returns:
38
+ True if added, False if device with same serial already exists
39
+ """
40
+ ...
41
+
42
+ def remove_device(self, serial: str, storage=None) -> bool:
43
+ """Remove a device from the manager.
44
+
45
+ Args:
46
+ serial: Serial number of device to remove (12 hex chars)
47
+ storage: Optional storage backend to delete persistent state
48
+
49
+ Returns:
50
+ True if removed, False if device not found
51
+ """
52
+ ...
53
+
54
+ def remove_all_devices(self, delete_storage: bool = False, storage=None) -> int:
55
+ """Remove all devices from the manager.
56
+
57
+ Args:
58
+ delete_storage: If True, also delete persistent storage files
59
+ storage: Storage backend to use for deletion
60
+
61
+ Returns:
62
+ Number of devices removed
63
+ """
64
+ ...
65
+
66
+ def get_device(self, serial: str) -> EmulatedLifxDevice | None:
67
+ """Get a device by serial number.
68
+
69
+ Args:
70
+ serial: Serial number (12 hex chars)
71
+
72
+ Returns:
73
+ Device if found, None otherwise
74
+ """
75
+ ...
76
+
77
+ def get_all_devices(self) -> list[EmulatedLifxDevice]:
78
+ """Get all devices.
79
+
80
+ Returns:
81
+ List of all devices
82
+ """
83
+ ...
84
+
85
+ def count_devices(self) -> int:
86
+ """Get the number of devices.
87
+
88
+ Returns:
89
+ Number of devices in the manager
90
+ """
91
+ ...
92
+
93
+ def resolve_target_devices(self, header: LifxHeader) -> list[EmulatedLifxDevice]:
94
+ """Resolve which devices should handle a packet based on the header.
95
+
96
+ Args:
97
+ header: Parsed LIFX header containing target information
98
+
99
+ Returns:
100
+ List of devices that should process this packet
101
+ """
102
+ ...
103
+
104
+ def invalidate_all_scenario_caches(self) -> None:
105
+ """Invalidate scenario cache for all devices.
106
+
107
+ This should be called when scenario configuration changes to ensure
108
+ devices reload their scenario settings from the scenario manager.
109
+ """
110
+ ...
111
+
112
+
113
+ class DeviceManager:
114
+ """Manages device lifecycle, routing, and lookup operations.
115
+
116
+ This class extracts device management logic from EmulatedLifxServer,
117
+ providing a clean separation between domain logic and network I/O.
118
+ It mirrors the architecture of HierarchicalScenarioManager.
119
+ """
120
+
121
+ def __init__(self, device_repository: IDeviceRepository):
122
+ """Initialize the device manager.
123
+
124
+ Args:
125
+ device_repository: Repository for device storage and retrieval
126
+ """
127
+ self._device_repository = device_repository
128
+
129
+ def add_device(
130
+ self,
131
+ device: EmulatedLifxDevice,
132
+ scenario_manager: HierarchicalScenarioManager | None = None,
133
+ ) -> bool:
134
+ """Add a device to the manager.
135
+
136
+ Args:
137
+ device: The device to add
138
+ scenario_manager: Optional scenario manager to share with the device
139
+
140
+ Returns:
141
+ True if added, False if device with same serial already exists
142
+ """
143
+ # If device is using HierarchicalScenarioManager, share the provided manager
144
+ if scenario_manager is not None:
145
+ from lifx_emulator.scenarios import HierarchicalScenarioManager
146
+
147
+ if isinstance(device.scenario_manager, HierarchicalScenarioManager):
148
+ device.scenario_manager = scenario_manager
149
+ device.invalidate_scenario_cache()
150
+
151
+ success = self._device_repository.add(device)
152
+ if success:
153
+ serial = device.state.serial
154
+ logger.info("Added device: %s (product=%s)", serial, device.state.product)
155
+ return success
156
+
157
+ def remove_device(self, serial: str, storage=None) -> bool:
158
+ """Remove a device from the manager.
159
+
160
+ Args:
161
+ serial: Serial number of device to remove (12 hex chars)
162
+ storage: Optional storage backend to delete persistent state
163
+
164
+ Returns:
165
+ True if removed, False if device not found
166
+ """
167
+ success = self._device_repository.remove(serial)
168
+ if success:
169
+ logger.info("Removed device: %s", serial)
170
+
171
+ # Delete persistent storage if enabled
172
+ if storage:
173
+ storage.delete_device_state(serial)
174
+
175
+ return success
176
+
177
+ def remove_all_devices(self, delete_storage: bool = False, storage=None) -> int:
178
+ """Remove all devices from the manager.
179
+
180
+ Args:
181
+ delete_storage: If True, also delete persistent storage files
182
+ storage: Storage backend to use for deletion
183
+
184
+ Returns:
185
+ Number of devices removed
186
+ """
187
+ # Clear all devices from repository
188
+ device_count = self._device_repository.clear()
189
+ logger.info("Removed all %s device(s)", device_count)
190
+
191
+ # Delete persistent storage if requested
192
+ if delete_storage and storage:
193
+ deleted = storage.delete_all_device_states()
194
+ logger.info("Deleted %s device state(s) from persistent storage", deleted)
195
+
196
+ return device_count
197
+
198
+ def get_device(self, serial: str) -> EmulatedLifxDevice | None:
199
+ """Get a device by serial number.
200
+
201
+ Args:
202
+ serial: Serial number (12 hex chars)
203
+
204
+ Returns:
205
+ Device if found, None otherwise
206
+ """
207
+ return self._device_repository.get(serial)
208
+
209
+ def get_all_devices(self) -> list[EmulatedLifxDevice]:
210
+ """Get all devices.
211
+
212
+ Returns:
213
+ List of all devices
214
+ """
215
+ return self._device_repository.get_all()
216
+
217
+ def count_devices(self) -> int:
218
+ """Get the number of devices.
219
+
220
+ Returns:
221
+ Number of devices in the manager
222
+ """
223
+ return self._device_repository.count()
224
+
225
+ def resolve_target_devices(self, header: LifxHeader) -> list[EmulatedLifxDevice]:
226
+ """Resolve which devices should handle a packet based on the header.
227
+
228
+ Args:
229
+ header: Parsed LIFX header containing target information
230
+
231
+ Returns:
232
+ List of devices that should process this packet
233
+ """
234
+ target_devices = []
235
+
236
+ if header.tagged or header.target == b"\x00" * 8:
237
+ # Broadcast to all devices
238
+ target_devices = self._device_repository.get_all()
239
+ else:
240
+ # Specific device - convert target bytes to serial string
241
+ # Target is 8 bytes: 6-byte MAC + 2 null bytes
242
+ target_serial = header.target[:6].hex()
243
+ device = self._device_repository.get(target_serial)
244
+ if device:
245
+ target_devices = [device]
246
+
247
+ return target_devices
248
+
249
+ def invalidate_all_scenario_caches(self) -> None:
250
+ """Invalidate scenario cache for all devices.
251
+
252
+ This should be called when scenario configuration changes to ensure
253
+ devices reload their scenario settings from the scenario manager.
254
+ """
255
+ for device in self._device_repository.get_all():
256
+ device.invalidate_scenario_cache()
@@ -9,7 +9,7 @@ from concurrent.futures import ThreadPoolExecutor
9
9
  from pathlib import Path
10
10
  from typing import Any
11
11
 
12
- from lifx_emulator.state_serializer import (
12
+ from lifx_emulator.devices.state_serializer import (
13
13
  deserialize_device_state,
14
14
  serialize_device_state,
15
15
  )
@@ -19,10 +19,10 @@ logger = logging.getLogger(__name__)
19
19
  DEFAULT_STORAGE_DIR = Path.home() / ".lifx-emulator"
20
20
 
21
21
 
22
- class AsyncDeviceStorage:
22
+ class DevicePersistenceAsyncFile:
23
23
  """High-performance async storage with smart debouncing.
24
24
 
25
- Implements AsyncStorageProtocol for non-blocking asynchronous I/O.
25
+ Non-blocking asynchronous I/O for device state persistence.
26
26
  Recommended for production use.
27
27
 
28
28
  Features:
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import logging
10
10
  from typing import Any
11
11
 
12
- from lifx_emulator.device import DeviceState
12
+ from lifx_emulator.devices.device import DeviceState
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
@@ -25,7 +25,7 @@ class StateRestorer:
25
25
  """Initialize state restorer.
26
26
 
27
27
  Args:
28
- storage: Storage instance (DeviceStorage or AsyncDeviceStorage)
28
+ storage: Storage instance (DeviceStorage or DevicePersistenceAsyncFile)
29
29
  """
30
30
  self.storage = storage
31
31