lifx-emulator 2.3.1__py3-none-any.whl → 3.0.1__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.0.1.dist-info/METADATA +102 -0
- lifx_emulator-3.0.1.dist-info/RECORD +18 -0
- lifx_emulator-3.0.1.dist-info/entry_points.txt +2 -0
- lifx_emulator_app/__init__.py +10 -0
- {lifx_emulator → lifx_emulator_app}/__main__.py +13 -5
- {lifx_emulator → lifx_emulator_app}/api/__init__.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/app.py +3 -3
- {lifx_emulator → lifx_emulator_app}/api/mappers/__init__.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/mappers/device_mapper.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/models.py +1 -2
- lifx_emulator_app/api/routers/__init__.py +11 -0
- {lifx_emulator → lifx_emulator_app}/api/routers/devices.py +2 -2
- {lifx_emulator → lifx_emulator_app}/api/routers/monitoring.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/routers/scenarios.py +1 -1
- lifx_emulator_app/api/services/__init__.py +8 -0
- {lifx_emulator → lifx_emulator_app}/api/services/device_service.py +3 -2
- lifx_emulator/__init__.py +0 -31
- lifx_emulator/api/routers/__init__.py +0 -11
- lifx_emulator/api/services/__init__.py +0 -8
- lifx_emulator/constants.py +0 -33
- lifx_emulator/devices/__init__.py +0 -37
- lifx_emulator/devices/device.py +0 -339
- lifx_emulator/devices/manager.py +0 -256
- lifx_emulator/devices/observers.py +0 -139
- lifx_emulator/devices/persistence.py +0 -308
- lifx_emulator/devices/state_restorer.py +0 -259
- lifx_emulator/devices/state_serializer.py +0 -157
- lifx_emulator/devices/states.py +0 -377
- lifx_emulator/factories/__init__.py +0 -37
- lifx_emulator/factories/builder.py +0 -373
- lifx_emulator/factories/default_config.py +0 -158
- lifx_emulator/factories/factory.py +0 -221
- lifx_emulator/factories/firmware_config.py +0 -77
- lifx_emulator/factories/serial_generator.py +0 -82
- lifx_emulator/handlers/__init__.py +0 -39
- lifx_emulator/handlers/base.py +0 -49
- lifx_emulator/handlers/device_handlers.py +0 -322
- lifx_emulator/handlers/light_handlers.py +0 -503
- lifx_emulator/handlers/multizone_handlers.py +0 -249
- lifx_emulator/handlers/registry.py +0 -110
- lifx_emulator/handlers/tile_handlers.py +0 -488
- lifx_emulator/products/__init__.py +0 -28
- lifx_emulator/products/generator.py +0 -1037
- lifx_emulator/products/registry.py +0 -1496
- lifx_emulator/products/specs.py +0 -284
- lifx_emulator/products/specs.yml +0 -352
- lifx_emulator/protocol/__init__.py +0 -1
- lifx_emulator/protocol/base.py +0 -446
- lifx_emulator/protocol/const.py +0 -8
- lifx_emulator/protocol/generator.py +0 -1384
- lifx_emulator/protocol/header.py +0 -159
- lifx_emulator/protocol/packets.py +0 -1351
- lifx_emulator/protocol/protocol_types.py +0 -817
- lifx_emulator/protocol/serializer.py +0 -379
- lifx_emulator/repositories/__init__.py +0 -22
- lifx_emulator/repositories/device_repository.py +0 -155
- lifx_emulator/repositories/storage_backend.py +0 -107
- lifx_emulator/scenarios/__init__.py +0 -22
- lifx_emulator/scenarios/manager.py +0 -322
- lifx_emulator/scenarios/models.py +0 -112
- lifx_emulator/scenarios/persistence.py +0 -241
- lifx_emulator/server.py +0 -464
- lifx_emulator-2.3.1.dist-info/METADATA +0 -107
- lifx_emulator-2.3.1.dist-info/RECORD +0 -62
- lifx_emulator-2.3.1.dist-info/entry_points.txt +0 -2
- lifx_emulator-2.3.1.dist-info/licenses/LICENSE +0 -35
- {lifx_emulator-2.3.1.dist-info → lifx_emulator-3.0.1.dist-info}/WHEEL +0 -0
- {lifx_emulator → lifx_emulator_app}/api/templates/dashboard.html +0 -0
lifx_emulator/devices/device.py
DELETED
|
@@ -1,339 +0,0 @@
|
|
|
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, TileFramebuffers
|
|
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
|
-
zones = 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(zones)
|
|
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
|
-
# Initialize framebuffer storage for each tile (framebuffers 1-7)
|
|
118
|
-
# Framebuffer 0 is stored in tile_devices[i]["colors"]
|
|
119
|
-
if not self.state.tile_framebuffers:
|
|
120
|
-
for i in range(self.state.tile_count):
|
|
121
|
-
self.state.tile_framebuffers.append(TileFramebuffers(tile_index=i))
|
|
122
|
-
|
|
123
|
-
# Save initial state if persistence is enabled
|
|
124
|
-
# This ensures newly created devices are immediately persisted
|
|
125
|
-
if self.storage:
|
|
126
|
-
self._save_state()
|
|
127
|
-
|
|
128
|
-
def get_uptime_ns(self) -> int:
|
|
129
|
-
"""Calculate current uptime in nanoseconds"""
|
|
130
|
-
return int((time.time() - self.start_time) * 1e9)
|
|
131
|
-
|
|
132
|
-
def _save_state(self) -> None:
|
|
133
|
-
"""Save device state asynchronously (non-blocking).
|
|
134
|
-
|
|
135
|
-
Creates a background task to save state without blocking the event loop.
|
|
136
|
-
The task is tracked to prevent garbage collection.
|
|
137
|
-
|
|
138
|
-
Note: Only DevicePersistenceAsyncFile is supported in production. For testing,
|
|
139
|
-
you can pass None to disable persistence.
|
|
140
|
-
"""
|
|
141
|
-
if not self.storage:
|
|
142
|
-
return
|
|
143
|
-
|
|
144
|
-
try:
|
|
145
|
-
loop = asyncio.get_running_loop()
|
|
146
|
-
task = loop.create_task(self.storage.save_device_state(self.state))
|
|
147
|
-
self._track_save_task(task)
|
|
148
|
-
except RuntimeError:
|
|
149
|
-
# No event loop (shouldn't happen in normal operation)
|
|
150
|
-
logger.error("Cannot save state for %s: no event loop", self.state.serial)
|
|
151
|
-
|
|
152
|
-
def _track_save_task(self, task: asyncio.Task) -> None:
|
|
153
|
-
"""Track background save task to prevent garbage collection.
|
|
154
|
-
|
|
155
|
-
Args:
|
|
156
|
-
task: Save task to track
|
|
157
|
-
"""
|
|
158
|
-
self.background_save_tasks.add(task)
|
|
159
|
-
task.add_done_callback(self.background_save_tasks.discard)
|
|
160
|
-
|
|
161
|
-
def _get_resolved_scenario(self) -> ScenarioConfig:
|
|
162
|
-
"""Get resolved scenario configuration with caching.
|
|
163
|
-
|
|
164
|
-
Resolves scenario from all applicable scopes and caches the result
|
|
165
|
-
for performance.
|
|
166
|
-
|
|
167
|
-
Returns:
|
|
168
|
-
ScenarioConfig with resolved settings
|
|
169
|
-
"""
|
|
170
|
-
if self._cached_scenario is not None:
|
|
171
|
-
return self._cached_scenario
|
|
172
|
-
|
|
173
|
-
# Resolve scenario with hierarchical scoping
|
|
174
|
-
self._cached_scenario = self.scenario_manager.get_scenario_for_device(
|
|
175
|
-
serial=self.state.serial,
|
|
176
|
-
device_type=get_device_type(self),
|
|
177
|
-
location=self.state.location_label,
|
|
178
|
-
group=self.state.group_label,
|
|
179
|
-
)
|
|
180
|
-
return self._cached_scenario
|
|
181
|
-
|
|
182
|
-
def invalidate_scenario_cache(self) -> None:
|
|
183
|
-
"""Invalidate cached scenario configuration.
|
|
184
|
-
|
|
185
|
-
Call this when scenarios are updated at runtime to force
|
|
186
|
-
recalculation on the next packet.
|
|
187
|
-
"""
|
|
188
|
-
self._cached_scenario = None
|
|
189
|
-
|
|
190
|
-
def _create_response_header(
|
|
191
|
-
self, source: int, sequence: int, pkt_type: int, payload_size: int
|
|
192
|
-
) -> LifxHeader:
|
|
193
|
-
"""Create response header using pre-allocated template (performance).
|
|
194
|
-
|
|
195
|
-
This method uses a pre-allocated template and creates a shallow copy,
|
|
196
|
-
then updates the fields. This avoids full __init__ and __post_init__
|
|
197
|
-
overhead while ensuring each response gets its own header object,
|
|
198
|
-
providing ~10% improvement in response generation.
|
|
199
|
-
|
|
200
|
-
Args:
|
|
201
|
-
source: Source identifier from request
|
|
202
|
-
sequence: Sequence number from request
|
|
203
|
-
pkt_type: Packet type for response
|
|
204
|
-
payload_size: Size of packed payload in bytes
|
|
205
|
-
|
|
206
|
-
Returns:
|
|
207
|
-
Configured LifxHeader ready to use
|
|
208
|
-
"""
|
|
209
|
-
# Shallow copy of template is faster than full construction with validation
|
|
210
|
-
header = copy.copy(self._response_header_template)
|
|
211
|
-
# Update fields for this specific response
|
|
212
|
-
header.source = source
|
|
213
|
-
header.sequence = sequence
|
|
214
|
-
header.pkt_type = pkt_type
|
|
215
|
-
header.size = LIFX_HEADER_SIZE + payload_size
|
|
216
|
-
return header
|
|
217
|
-
|
|
218
|
-
def process_packet(
|
|
219
|
-
self, header: LifxHeader, packet: Any | None
|
|
220
|
-
) -> list[tuple[LifxHeader, Any]]:
|
|
221
|
-
"""Process incoming packet and return response packets"""
|
|
222
|
-
responses = []
|
|
223
|
-
|
|
224
|
-
# Get resolved scenario configuration (cached for performance)
|
|
225
|
-
scenario = self._get_resolved_scenario()
|
|
226
|
-
|
|
227
|
-
# Check if packet should be dropped (with probabilistic drops)
|
|
228
|
-
if not self.scenario_manager.should_respond(header.pkt_type, scenario):
|
|
229
|
-
logger.info("Dropping packet type %s per scenario", header.pkt_type)
|
|
230
|
-
return responses
|
|
231
|
-
|
|
232
|
-
# Update uptime
|
|
233
|
-
self.state.uptime_ns = self.get_uptime_ns()
|
|
234
|
-
|
|
235
|
-
# Handle acknowledgment (packet type 45, no payload)
|
|
236
|
-
if header.ack_required:
|
|
237
|
-
ack_packet = Device.Acknowledgement()
|
|
238
|
-
ack_payload = ack_packet.pack()
|
|
239
|
-
ack_header = self._create_response_header(
|
|
240
|
-
header.source,
|
|
241
|
-
header.sequence,
|
|
242
|
-
ack_packet.PKT_TYPE,
|
|
243
|
-
len(ack_payload),
|
|
244
|
-
)
|
|
245
|
-
# Store header, packet, and pre-packed payload
|
|
246
|
-
# (consistent with response format)
|
|
247
|
-
responses.append((ack_header, ack_packet, ack_payload))
|
|
248
|
-
|
|
249
|
-
# Handle specific packet types - handlers always return list
|
|
250
|
-
response_packets = self._handle_packet_type(header, packet)
|
|
251
|
-
# Handlers now always return list (empty if no response)
|
|
252
|
-
for resp_packet in response_packets:
|
|
253
|
-
# Cache packed payload to avoid double packing (performance optimization)
|
|
254
|
-
resp_payload = resp_packet.pack()
|
|
255
|
-
resp_header = self._create_response_header(
|
|
256
|
-
header.source,
|
|
257
|
-
header.sequence,
|
|
258
|
-
resp_packet.PKT_TYPE,
|
|
259
|
-
len(resp_payload),
|
|
260
|
-
)
|
|
261
|
-
# Store both header and pre-packed payload for error scenario processing
|
|
262
|
-
responses.append((resp_header, resp_packet, resp_payload))
|
|
263
|
-
|
|
264
|
-
# Apply error scenarios to responses
|
|
265
|
-
modified_responses = []
|
|
266
|
-
for resp_header, resp_packet, resp_payload in responses:
|
|
267
|
-
# Check if we should send malformed packet (truncate payload)
|
|
268
|
-
if resp_header.pkt_type in scenario.malformed_packets:
|
|
269
|
-
# For malformed packets, truncate the pre-packed payload
|
|
270
|
-
truncated_len = len(resp_payload) // 2
|
|
271
|
-
resp_payload_modified = resp_payload[:truncated_len]
|
|
272
|
-
resp_header.size = LIFX_HEADER_SIZE + truncated_len + 10 # Wrong size
|
|
273
|
-
# Convert back to bytes for malformed case
|
|
274
|
-
modified_responses.append((resp_header, resp_payload_modified))
|
|
275
|
-
logger.info(
|
|
276
|
-
"Sending malformed packet type %s (truncated)", resp_header.pkt_type
|
|
277
|
-
)
|
|
278
|
-
continue
|
|
279
|
-
|
|
280
|
-
# Check if we should send invalid field values
|
|
281
|
-
if resp_header.pkt_type in scenario.invalid_field_values:
|
|
282
|
-
# Corrupt the pre-packed payload
|
|
283
|
-
resp_payload_modified = b"\xff" * len(resp_payload)
|
|
284
|
-
modified_responses.append((resp_header, resp_payload_modified))
|
|
285
|
-
pkt_type = resp_header.pkt_type
|
|
286
|
-
logger.info("Sending invalid field values for packet type %s", pkt_type)
|
|
287
|
-
continue
|
|
288
|
-
|
|
289
|
-
# Normal case: use original packet object (will be packed later by server)
|
|
290
|
-
modified_responses.append((resp_header, resp_packet))
|
|
291
|
-
|
|
292
|
-
return modified_responses
|
|
293
|
-
|
|
294
|
-
def _handle_packet_type(self, header: LifxHeader, packet: Any | None) -> list[Any]:
|
|
295
|
-
"""Handle specific packet types using registered handlers.
|
|
296
|
-
|
|
297
|
-
Returns:
|
|
298
|
-
List of response packets (empty list if no response)
|
|
299
|
-
"""
|
|
300
|
-
pkt_type = header.pkt_type
|
|
301
|
-
|
|
302
|
-
# Update uptime for this packet
|
|
303
|
-
self.state.uptime_ns = self.get_uptime_ns()
|
|
304
|
-
|
|
305
|
-
# Find handler for this packet type
|
|
306
|
-
handler = self.handlers.get_handler(pkt_type)
|
|
307
|
-
|
|
308
|
-
if handler:
|
|
309
|
-
# Delegate to handler (always returns list now)
|
|
310
|
-
response = handler.handle(self.state, packet, header.res_required)
|
|
311
|
-
|
|
312
|
-
# Save state if storage is enabled (for SET operations)
|
|
313
|
-
if packet and self.storage:
|
|
314
|
-
self._save_state()
|
|
315
|
-
|
|
316
|
-
return response
|
|
317
|
-
else:
|
|
318
|
-
# Unknown/unimplemented packet type
|
|
319
|
-
from lifx_emulator.protocol.packets import get_packet_class
|
|
320
|
-
|
|
321
|
-
packet_class = get_packet_class(pkt_type)
|
|
322
|
-
if packet_class:
|
|
323
|
-
logger.info(
|
|
324
|
-
"Device %s: Received %s (type %s) but no handler registered",
|
|
325
|
-
self.state.serial,
|
|
326
|
-
packet_class.__qualname__,
|
|
327
|
-
pkt_type,
|
|
328
|
-
)
|
|
329
|
-
else:
|
|
330
|
-
serial = self.state.serial
|
|
331
|
-
logger.warning(
|
|
332
|
-
"Device %s: Received unknown packet type %s", serial, pkt_type
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
# Check scenario for StateUnhandled response
|
|
336
|
-
scenario = self._get_resolved_scenario()
|
|
337
|
-
if scenario.send_unhandled:
|
|
338
|
-
return [Device.StateUnhandled(unhandled_type=pkt_type)]
|
|
339
|
-
return []
|
lifx_emulator/devices/manager.py
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
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()
|