lifx-emulator 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lifx_emulator/__init__.py +31 -0
- lifx_emulator/__main__.py +607 -0
- lifx_emulator/api.py +1825 -0
- lifx_emulator/async_storage.py +308 -0
- lifx_emulator/constants.py +33 -0
- lifx_emulator/device.py +750 -0
- lifx_emulator/device_states.py +114 -0
- lifx_emulator/factories.py +380 -0
- lifx_emulator/handlers/__init__.py +39 -0
- lifx_emulator/handlers/base.py +49 -0
- lifx_emulator/handlers/device_handlers.py +340 -0
- lifx_emulator/handlers/light_handlers.py +372 -0
- lifx_emulator/handlers/multizone_handlers.py +249 -0
- lifx_emulator/handlers/registry.py +110 -0
- lifx_emulator/handlers/tile_handlers.py +309 -0
- lifx_emulator/observers.py +139 -0
- lifx_emulator/products/__init__.py +28 -0
- lifx_emulator/products/generator.py +771 -0
- lifx_emulator/products/registry.py +1446 -0
- lifx_emulator/products/specs.py +242 -0
- lifx_emulator/products/specs.yml +327 -0
- lifx_emulator/protocol/__init__.py +1 -0
- lifx_emulator/protocol/base.py +334 -0
- lifx_emulator/protocol/const.py +8 -0
- lifx_emulator/protocol/generator.py +1371 -0
- lifx_emulator/protocol/header.py +159 -0
- lifx_emulator/protocol/packets.py +1351 -0
- lifx_emulator/protocol/protocol_types.py +844 -0
- lifx_emulator/protocol/serializer.py +379 -0
- lifx_emulator/scenario_manager.py +402 -0
- lifx_emulator/scenario_persistence.py +206 -0
- lifx_emulator/server.py +482 -0
- lifx_emulator/state_restorer.py +259 -0
- lifx_emulator/state_serializer.py +130 -0
- lifx_emulator/storage_protocol.py +100 -0
- lifx_emulator-1.0.0.dist-info/METADATA +445 -0
- lifx_emulator-1.0.0.dist-info/RECORD +40 -0
- lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
- lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
- lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Hierarchical scenario management for testing LIFX protocol behavior.
|
|
2
|
+
|
|
3
|
+
This module provides a flexible scenario system that allows configuring
|
|
4
|
+
test scenarios at multiple scopes with precedence-based resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import random
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from lifx_emulator.device import EmulatedLifxDevice
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ScenarioConfig:
|
|
17
|
+
"""Individual scenario configuration.
|
|
18
|
+
|
|
19
|
+
Scenarios define testing behaviors for the emulator, such as:
|
|
20
|
+
- Dropping specific packet types (no response)
|
|
21
|
+
- Adding response delays
|
|
22
|
+
- Sending malformed/corrupted responses
|
|
23
|
+
- Overriding firmware version
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
drop_packets: dict[int, float] = field(default_factory=dict)
|
|
27
|
+
"""Packet types to drop with drop rates (0.1-1.0).
|
|
28
|
+
|
|
29
|
+
Maps packet type to drop rate where:
|
|
30
|
+
- 1.0 = always drop (100%)
|
|
31
|
+
- 0.5 = drop 50% of packets
|
|
32
|
+
- 0.1 = drop 10% of packets
|
|
33
|
+
Examples: {101: 1.0} (always drop), {102: 0.6} (drop 60% of packets)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
response_delays: dict[int, float] = field(default_factory=dict)
|
|
37
|
+
"""Response delays in seconds by packet type"""
|
|
38
|
+
|
|
39
|
+
malformed_packets: list[int] = field(default_factory=list)
|
|
40
|
+
"""Packet types to send truncated/corrupted"""
|
|
41
|
+
|
|
42
|
+
invalid_field_values: list[int] = field(default_factory=list)
|
|
43
|
+
"""Packet types to send with all 0xFF bytes"""
|
|
44
|
+
|
|
45
|
+
firmware_version: tuple[int, int] | None = None
|
|
46
|
+
"""Override firmware version (major, minor)"""
|
|
47
|
+
|
|
48
|
+
partial_responses: list[int] = field(default_factory=list)
|
|
49
|
+
"""Packet types to send incomplete multizone/tile data"""
|
|
50
|
+
|
|
51
|
+
send_unhandled: bool = False
|
|
52
|
+
"""Send StateUnhandled for unknown packet types"""
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""Convert to dictionary for API serialization."""
|
|
56
|
+
return {
|
|
57
|
+
"drop_packets": {str(k): v for k, v in self.drop_packets.items()},
|
|
58
|
+
"response_delays": {str(k): v for k, v in self.response_delays.items()},
|
|
59
|
+
"malformed_packets": self.malformed_packets,
|
|
60
|
+
"invalid_field_values": self.invalid_field_values,
|
|
61
|
+
"firmware_version": self.firmware_version,
|
|
62
|
+
"partial_responses": self.partial_responses,
|
|
63
|
+
"send_unhandled": self.send_unhandled,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, data: dict[str, Any]) -> "ScenarioConfig":
|
|
68
|
+
"""Create from dictionary (API input)."""
|
|
69
|
+
firmware = data.get("firmware_version")
|
|
70
|
+
if firmware and not isinstance(firmware, tuple):
|
|
71
|
+
firmware = tuple(firmware)
|
|
72
|
+
|
|
73
|
+
# Convert drop_packets keys from string to int
|
|
74
|
+
drop_packets_input = data.get("drop_packets", {})
|
|
75
|
+
drop_packets = (
|
|
76
|
+
{int(k): v for k, v in drop_packets_input.items()}
|
|
77
|
+
if isinstance(drop_packets_input, dict)
|
|
78
|
+
else {}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Convert response_delays keys from string to int
|
|
82
|
+
response_delays_input = data.get("response_delays", {})
|
|
83
|
+
response_delays = (
|
|
84
|
+
{int(k): v for k, v in response_delays_input.items()}
|
|
85
|
+
if isinstance(response_delays_input, dict)
|
|
86
|
+
else {}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return cls(
|
|
90
|
+
drop_packets=drop_packets,
|
|
91
|
+
response_delays=response_delays,
|
|
92
|
+
malformed_packets=data.get("malformed_packets", []),
|
|
93
|
+
invalid_field_values=data.get("invalid_field_values", []),
|
|
94
|
+
firmware_version=firmware,
|
|
95
|
+
partial_responses=data.get("partial_responses", []),
|
|
96
|
+
send_unhandled=data.get("send_unhandled", False),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_device_type(device: "EmulatedLifxDevice") -> str:
|
|
101
|
+
"""Get device type identifier for scenario scoping.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
device: EmulatedLifxDevice instance
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Device type string (matrix, extended_multizone, multizone,
|
|
108
|
+
hev, infrared, color, or basic)
|
|
109
|
+
"""
|
|
110
|
+
if device.state.has_matrix:
|
|
111
|
+
return "matrix"
|
|
112
|
+
elif device.state.has_extended_multizone:
|
|
113
|
+
return "extended_multizone"
|
|
114
|
+
elif device.state.has_multizone:
|
|
115
|
+
return "multizone"
|
|
116
|
+
elif device.state.has_hev:
|
|
117
|
+
return "hev"
|
|
118
|
+
elif device.state.has_infrared:
|
|
119
|
+
return "infrared"
|
|
120
|
+
elif device.state.has_color:
|
|
121
|
+
return "color"
|
|
122
|
+
else:
|
|
123
|
+
return "basic"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class HierarchicalScenarioManager:
|
|
127
|
+
"""Manages scenarios across multiple scopes with precedence resolution.
|
|
128
|
+
|
|
129
|
+
Supports 5 scope levels with precedence (specific to general):
|
|
130
|
+
1. Device-specific (by serial) - Highest priority
|
|
131
|
+
2. Device-type specific (by capability: color, multizone, matrix, etc.)
|
|
132
|
+
3. Location-specific (all devices in location)
|
|
133
|
+
4. Group-specific (all devices in group)
|
|
134
|
+
5. Global (all emulated devices) - Lowest priority
|
|
135
|
+
|
|
136
|
+
When resolving scenarios for a device, configurations from all applicable
|
|
137
|
+
scopes are merged using union semantics for lists and override semantics
|
|
138
|
+
for scalars.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self):
|
|
142
|
+
# Scenario storage by scope
|
|
143
|
+
self.device_scenarios: dict[str, ScenarioConfig] = {} # serial → config
|
|
144
|
+
self.type_scenarios: dict[str, ScenarioConfig] = {} # type → config
|
|
145
|
+
self.location_scenarios: dict[str, ScenarioConfig] = {} # location → config
|
|
146
|
+
self.group_scenarios: dict[str, ScenarioConfig] = {} # group → config
|
|
147
|
+
self.global_scenario: ScenarioConfig = ScenarioConfig()
|
|
148
|
+
|
|
149
|
+
def set_device_scenario(self, serial: str, config: ScenarioConfig):
|
|
150
|
+
"""Set scenario for specific device by serial."""
|
|
151
|
+
self.device_scenarios[serial] = config
|
|
152
|
+
|
|
153
|
+
def set_type_scenario(self, device_type: str, config: ScenarioConfig):
|
|
154
|
+
"""Set scenario for device type (color, multizone, matrix, etc.)."""
|
|
155
|
+
self.type_scenarios[device_type] = config
|
|
156
|
+
|
|
157
|
+
def set_location_scenario(self, location: str, config: ScenarioConfig):
|
|
158
|
+
"""Set scenario for all devices in a location."""
|
|
159
|
+
self.location_scenarios[location] = config
|
|
160
|
+
|
|
161
|
+
def set_group_scenario(self, group: str, config: ScenarioConfig):
|
|
162
|
+
"""Set scenario for all devices in a group."""
|
|
163
|
+
self.group_scenarios[group] = config
|
|
164
|
+
|
|
165
|
+
def set_global_scenario(self, config: ScenarioConfig):
|
|
166
|
+
"""Set global scenario for all devices."""
|
|
167
|
+
self.global_scenario = config
|
|
168
|
+
|
|
169
|
+
def delete_device_scenario(self, serial: str) -> bool:
|
|
170
|
+
"""Delete device-specific scenario. Returns True if existed."""
|
|
171
|
+
return self.device_scenarios.pop(serial, None) is not None
|
|
172
|
+
|
|
173
|
+
def delete_type_scenario(self, device_type: str) -> bool:
|
|
174
|
+
"""Delete type-specific scenario. Returns True if existed."""
|
|
175
|
+
return self.type_scenarios.pop(device_type, None) is not None
|
|
176
|
+
|
|
177
|
+
def delete_location_scenario(self, location: str) -> bool:
|
|
178
|
+
"""Delete location-specific scenario. Returns True if existed."""
|
|
179
|
+
return self.location_scenarios.pop(location, None) is not None
|
|
180
|
+
|
|
181
|
+
def delete_group_scenario(self, group: str) -> bool:
|
|
182
|
+
"""Delete group-specific scenario. Returns True if existed."""
|
|
183
|
+
return self.group_scenarios.pop(group, None) is not None
|
|
184
|
+
|
|
185
|
+
def clear_global_scenario(self):
|
|
186
|
+
"""Clear global scenario (reset to empty)."""
|
|
187
|
+
self.global_scenario = ScenarioConfig()
|
|
188
|
+
|
|
189
|
+
def get_global_scenario(self) -> ScenarioConfig:
|
|
190
|
+
"""Get global scenario configuration."""
|
|
191
|
+
return self.global_scenario
|
|
192
|
+
|
|
193
|
+
def get_device_scenario(self, serial: str) -> ScenarioConfig | None:
|
|
194
|
+
"""Get device-specific scenario by serial.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
serial: Device serial number
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
ScenarioConfig if scenario exists, None otherwise
|
|
201
|
+
"""
|
|
202
|
+
return self.device_scenarios.get(serial)
|
|
203
|
+
|
|
204
|
+
def get_type_scenario(self, device_type: str) -> ScenarioConfig | None:
|
|
205
|
+
"""Get type-specific scenario.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
device_type: Device type (color, multizone, matrix, etc.)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
ScenarioConfig if scenario exists, None otherwise
|
|
212
|
+
"""
|
|
213
|
+
return self.type_scenarios.get(device_type)
|
|
214
|
+
|
|
215
|
+
def get_location_scenario(self, location: str) -> ScenarioConfig | None:
|
|
216
|
+
"""Get location-specific scenario.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
location: Device location label
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
ScenarioConfig if scenario exists, None otherwise
|
|
223
|
+
"""
|
|
224
|
+
return self.location_scenarios.get(location)
|
|
225
|
+
|
|
226
|
+
def get_group_scenario(self, group: str) -> ScenarioConfig | None:
|
|
227
|
+
"""Get group-specific scenario.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
group: Device group label
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
ScenarioConfig if scenario exists, None otherwise
|
|
234
|
+
"""
|
|
235
|
+
return self.group_scenarios.get(group)
|
|
236
|
+
|
|
237
|
+
def get_scenario_for_device(
|
|
238
|
+
self,
|
|
239
|
+
serial: str,
|
|
240
|
+
device_type: str,
|
|
241
|
+
location: str,
|
|
242
|
+
group: str,
|
|
243
|
+
) -> ScenarioConfig:
|
|
244
|
+
"""Resolve scenario for device with precedence.
|
|
245
|
+
|
|
246
|
+
Precedence (highest to lowest):
|
|
247
|
+
1. Device-specific
|
|
248
|
+
2. Device-type
|
|
249
|
+
3. Location
|
|
250
|
+
4. Group
|
|
251
|
+
5. Global
|
|
252
|
+
|
|
253
|
+
Returns merged configuration with most specific values taking priority.
|
|
254
|
+
Lists are merged using union (all values combined).
|
|
255
|
+
Dicts are merged with later values overriding earlier.
|
|
256
|
+
Scalars use the most specific non-None value.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
serial: Device serial number
|
|
260
|
+
device_type: Device type (color, multizone, matrix, etc.)
|
|
261
|
+
location: Device location label
|
|
262
|
+
group: Device group label
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Merged ScenarioConfig
|
|
266
|
+
"""
|
|
267
|
+
# Start with empty config
|
|
268
|
+
merged = ScenarioConfig()
|
|
269
|
+
|
|
270
|
+
# Layer in each scope (general to specific)
|
|
271
|
+
# Later scopes override or merge with earlier ones
|
|
272
|
+
for config in [
|
|
273
|
+
self.global_scenario,
|
|
274
|
+
self.group_scenarios.get(group),
|
|
275
|
+
self.location_scenarios.get(location),
|
|
276
|
+
self.type_scenarios.get(device_type),
|
|
277
|
+
self.device_scenarios.get(serial),
|
|
278
|
+
]:
|
|
279
|
+
if config is None:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
# Merge drop_packets dict (later overwrites earlier)
|
|
283
|
+
merged.drop_packets.update(config.drop_packets)
|
|
284
|
+
|
|
285
|
+
# Merge lists using union (combine all values)
|
|
286
|
+
merged.malformed_packets = list(
|
|
287
|
+
set(merged.malformed_packets + config.malformed_packets)
|
|
288
|
+
)
|
|
289
|
+
merged.invalid_field_values = list(
|
|
290
|
+
set(merged.invalid_field_values + config.invalid_field_values)
|
|
291
|
+
)
|
|
292
|
+
merged.partial_responses = list(
|
|
293
|
+
set(merged.partial_responses + config.partial_responses)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Merge delays dict (later overwrites earlier)
|
|
297
|
+
merged.response_delays.update(config.response_delays)
|
|
298
|
+
|
|
299
|
+
# Scalars: use most specific non-default value
|
|
300
|
+
if config.firmware_version is not None:
|
|
301
|
+
merged.firmware_version = config.firmware_version
|
|
302
|
+
if config.send_unhandled:
|
|
303
|
+
merged.send_unhandled = True
|
|
304
|
+
|
|
305
|
+
return merged
|
|
306
|
+
|
|
307
|
+
def should_respond(self, packet_type: int, scenario: ScenarioConfig) -> bool:
|
|
308
|
+
"""Check if device should respond to packet type.
|
|
309
|
+
|
|
310
|
+
Uses probabilistic dropping based on drop_packets configuration.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
packet_type: LIFX packet type number
|
|
314
|
+
scenario: Resolved scenario configuration
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
False if packet should be dropped, True otherwise
|
|
318
|
+
"""
|
|
319
|
+
if packet_type not in scenario.drop_packets:
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
# Get drop rate for this packet type (0.1-1.0)
|
|
323
|
+
drop_rate = scenario.drop_packets[packet_type]
|
|
324
|
+
|
|
325
|
+
# Probabilistic drop: random value [0, 1) < drop_rate means drop
|
|
326
|
+
return random.random() >= drop_rate # nosec
|
|
327
|
+
|
|
328
|
+
def get_response_delay(self, packet_type: int, scenario: ScenarioConfig) -> float:
|
|
329
|
+
"""Get response delay for packet type.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
packet_type: LIFX packet type number
|
|
333
|
+
scenario: Resolved scenario configuration
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Delay in seconds (0.0 if no delay configured)
|
|
337
|
+
"""
|
|
338
|
+
return scenario.response_delays.get(packet_type, 0.0)
|
|
339
|
+
|
|
340
|
+
def should_send_malformed(self, packet_type: int, scenario: ScenarioConfig) -> bool:
|
|
341
|
+
"""Check if response should be malformed/truncated.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
packet_type: LIFX packet type number
|
|
345
|
+
scenario: Resolved scenario configuration
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
True if response should be corrupted
|
|
349
|
+
"""
|
|
350
|
+
return packet_type in scenario.malformed_packets
|
|
351
|
+
|
|
352
|
+
def should_send_invalid_fields(
|
|
353
|
+
self, packet_type: int, scenario: ScenarioConfig
|
|
354
|
+
) -> bool:
|
|
355
|
+
"""Check if response should have invalid field values (all 0xFF).
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
packet_type: LIFX packet type number
|
|
359
|
+
scenario: Resolved scenario configuration
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
True if response should have invalid fields
|
|
363
|
+
"""
|
|
364
|
+
return packet_type in scenario.invalid_field_values
|
|
365
|
+
|
|
366
|
+
def get_firmware_version_override(
|
|
367
|
+
self, scenario: ScenarioConfig
|
|
368
|
+
) -> tuple[int, int] | None:
|
|
369
|
+
"""Get firmware version override if configured.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
scenario: Resolved scenario configuration
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
(major, minor) tuple or None
|
|
376
|
+
"""
|
|
377
|
+
return scenario.firmware_version
|
|
378
|
+
|
|
379
|
+
def should_send_partial_response(
|
|
380
|
+
self, packet_type: int, scenario: ScenarioConfig
|
|
381
|
+
) -> bool:
|
|
382
|
+
"""Check if response should be partial (incomplete multizone/tile data).
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
packet_type: LIFX packet type number
|
|
386
|
+
scenario: Resolved scenario configuration
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
True if response should be incomplete
|
|
390
|
+
"""
|
|
391
|
+
return packet_type in scenario.partial_responses
|
|
392
|
+
|
|
393
|
+
def should_send_unhandled(self, scenario: ScenarioConfig) -> bool:
|
|
394
|
+
"""Check if StateUnhandled should be sent for unknown packet types.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
scenario: Resolved scenario configuration
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
True if StateUnhandled should be sent
|
|
401
|
+
"""
|
|
402
|
+
return scenario.send_unhandled
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Persistence layer for scenario configurations.
|
|
2
|
+
|
|
3
|
+
This module provides JSON serialization and deserialization for scenarios,
|
|
4
|
+
allowing them to persist across emulator restarts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from lifx_emulator.scenario_manager import HierarchicalScenarioManager, ScenarioConfig
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScenarioPersistence:
|
|
18
|
+
"""Handles scenario persistence to disk.
|
|
19
|
+
|
|
20
|
+
Scenarios are stored in JSON format at ~/.lifx-emulator/scenarios.json
|
|
21
|
+
with separate sections for each scope level.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, storage_path: Path | None = None):
|
|
25
|
+
"""Initialize scenario persistence.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
storage_path: Directory to store scenarios.json
|
|
29
|
+
Defaults to ~/.lifx-emulator
|
|
30
|
+
"""
|
|
31
|
+
if storage_path is None:
|
|
32
|
+
storage_path = Path.home() / ".lifx-emulator"
|
|
33
|
+
|
|
34
|
+
self.storage_path = Path(storage_path)
|
|
35
|
+
self.scenario_file = self.storage_path / "scenarios.json"
|
|
36
|
+
|
|
37
|
+
def load(self) -> HierarchicalScenarioManager:
|
|
38
|
+
"""Load scenarios from disk.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
HierarchicalScenarioManager with loaded scenarios.
|
|
42
|
+
If file doesn't exist, returns empty manager.
|
|
43
|
+
"""
|
|
44
|
+
manager = HierarchicalScenarioManager()
|
|
45
|
+
|
|
46
|
+
if not self.scenario_file.exists():
|
|
47
|
+
logger.debug("No scenario file found at %s", self.scenario_file)
|
|
48
|
+
return manager
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with open(self.scenario_file) as f:
|
|
52
|
+
data = json.load(f)
|
|
53
|
+
|
|
54
|
+
# Load global scenario
|
|
55
|
+
if "global" in data and data["global"]:
|
|
56
|
+
manager.global_scenario = ScenarioConfig.from_dict(data["global"])
|
|
57
|
+
logger.debug("Loaded global scenario")
|
|
58
|
+
|
|
59
|
+
# Load device-specific scenarios
|
|
60
|
+
for serial, config_data in data.get("devices", {}).items():
|
|
61
|
+
manager.device_scenarios[serial] = ScenarioConfig.from_dict(config_data)
|
|
62
|
+
if manager.device_scenarios:
|
|
63
|
+
logger.debug(
|
|
64
|
+
"Loaded %s device scenario(s)", len(manager.device_scenarios)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Load type-specific scenarios
|
|
68
|
+
for device_type, config_data in data.get("types", {}).items():
|
|
69
|
+
manager.type_scenarios[device_type] = ScenarioConfig.from_dict(
|
|
70
|
+
config_data
|
|
71
|
+
)
|
|
72
|
+
if manager.type_scenarios:
|
|
73
|
+
logger.debug("Loaded %s type scenario(s)", len(manager.type_scenarios))
|
|
74
|
+
|
|
75
|
+
# Load location-specific scenarios
|
|
76
|
+
for location, config_data in data.get("locations", {}).items():
|
|
77
|
+
manager.location_scenarios[location] = ScenarioConfig.from_dict(
|
|
78
|
+
config_data
|
|
79
|
+
)
|
|
80
|
+
if manager.location_scenarios:
|
|
81
|
+
logger.debug(
|
|
82
|
+
"Loaded %s location scenario(s)", len(manager.location_scenarios)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Load group-specific scenarios
|
|
86
|
+
for group, config_data in data.get("groups", {}).items():
|
|
87
|
+
manager.group_scenarios[group] = ScenarioConfig.from_dict(config_data)
|
|
88
|
+
if manager.group_scenarios:
|
|
89
|
+
logger.debug(
|
|
90
|
+
"Loaded %s group scenario(s)", len(manager.group_scenarios)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
logger.info("Loaded scenarios from %s", self.scenario_file)
|
|
94
|
+
return manager
|
|
95
|
+
|
|
96
|
+
except json.JSONDecodeError as e:
|
|
97
|
+
logger.error("Failed to parse scenario file %s: %s", self.scenario_file, e)
|
|
98
|
+
return manager
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.error("Failed to load scenarios from %s: %s", self.scenario_file, e)
|
|
101
|
+
return manager
|
|
102
|
+
|
|
103
|
+
def save(self, manager: HierarchicalScenarioManager) -> None:
|
|
104
|
+
"""Save scenarios to disk.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
manager: HierarchicalScenarioManager to save
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# Convert response_delays keys to strings for JSON serialization
|
|
111
|
+
def _serialize_config(config: ScenarioConfig) -> dict[str, Any]:
|
|
112
|
+
"""Convert ScenarioConfig to JSON-serializable dict."""
|
|
113
|
+
data = config.to_dict()
|
|
114
|
+
# Convert int keys in response_delays to strings
|
|
115
|
+
if data.get("response_delays"):
|
|
116
|
+
data["response_delays"] = {
|
|
117
|
+
str(k): v for k, v in data["response_delays"].items()
|
|
118
|
+
}
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
data: dict[str, Any] = {
|
|
122
|
+
"global": _serialize_config(manager.global_scenario),
|
|
123
|
+
"devices": {
|
|
124
|
+
serial: _serialize_config(config)
|
|
125
|
+
for serial, config in manager.device_scenarios.items()
|
|
126
|
+
},
|
|
127
|
+
"types": {
|
|
128
|
+
device_type: _serialize_config(config)
|
|
129
|
+
for device_type, config in manager.type_scenarios.items()
|
|
130
|
+
},
|
|
131
|
+
"locations": {
|
|
132
|
+
location: _serialize_config(config)
|
|
133
|
+
for location, config in manager.location_scenarios.items()
|
|
134
|
+
},
|
|
135
|
+
"groups": {
|
|
136
|
+
group: _serialize_config(config)
|
|
137
|
+
for group, config in manager.group_scenarios.items()
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
# Create directory if it doesn't exist
|
|
143
|
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
|
|
145
|
+
# Write to temporary file first, then rename (atomic operation)
|
|
146
|
+
temp_file = self.scenario_file.with_suffix(".json.tmp")
|
|
147
|
+
with open(temp_file, "w") as f:
|
|
148
|
+
json.dump(data, f, indent=2)
|
|
149
|
+
|
|
150
|
+
# Atomic rename
|
|
151
|
+
temp_file.replace(self.scenario_file)
|
|
152
|
+
|
|
153
|
+
logger.info("Saved scenarios to %s", self.scenario_file)
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.error("Failed to save scenarios to %s: %s", self.scenario_file, e)
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
def delete(self) -> bool:
|
|
160
|
+
"""Delete the scenario file.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
True if file was deleted, False if it didn't exist
|
|
164
|
+
"""
|
|
165
|
+
if self.scenario_file.exists():
|
|
166
|
+
try:
|
|
167
|
+
self.scenario_file.unlink()
|
|
168
|
+
logger.info("Deleted scenario file %s", self.scenario_file)
|
|
169
|
+
return True
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error("Failed to delete scenario file: %s", e)
|
|
172
|
+
raise
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _deserialize_response_delays(data: dict[str, Any]) -> dict[int, float]:
|
|
177
|
+
"""Convert string keys back to integers for response_delays.
|
|
178
|
+
|
|
179
|
+
JSON only supports string keys, so we convert them back to ints.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
data: Dictionary with string keys
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Dictionary with integer keys
|
|
186
|
+
"""
|
|
187
|
+
if not data:
|
|
188
|
+
return {}
|
|
189
|
+
return {int(k): v for k, v in data.items()}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Monkey-patch ScenarioConfig.from_dict to handle string keys
|
|
193
|
+
_original_from_dict = ScenarioConfig.from_dict
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def _from_dict_with_conversion(cls, data: dict[str, Any]) -> ScenarioConfig:
|
|
198
|
+
"""Create from dictionary with int key conversion."""
|
|
199
|
+
# Convert response_delays string keys to ints
|
|
200
|
+
if "response_delays" in data and data["response_delays"]:
|
|
201
|
+
data = data.copy()
|
|
202
|
+
data["response_delays"] = _deserialize_response_delays(data["response_delays"])
|
|
203
|
+
return _original_from_dict(data)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
ScenarioConfig.from_dict = _from_dict_with_conversion # type: ignore
|