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
|
@@ -1,322 +0,0 @@
|
|
|
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 typing import TYPE_CHECKING
|
|
9
|
-
|
|
10
|
-
from lifx_emulator.scenarios.models import ScenarioConfig
|
|
11
|
-
|
|
12
|
-
if TYPE_CHECKING:
|
|
13
|
-
from lifx_emulator.devices import EmulatedLifxDevice
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def get_device_type(device: "EmulatedLifxDevice") -> str:
|
|
17
|
-
"""Get device type identifier for scenario scoping.
|
|
18
|
-
|
|
19
|
-
Args:
|
|
20
|
-
device: EmulatedLifxDevice instance
|
|
21
|
-
|
|
22
|
-
Returns:
|
|
23
|
-
Device type string (matrix, extended_multizone, multizone,
|
|
24
|
-
hev, infrared, color, or basic)
|
|
25
|
-
"""
|
|
26
|
-
if device.state.has_matrix:
|
|
27
|
-
return "matrix"
|
|
28
|
-
elif device.state.has_extended_multizone:
|
|
29
|
-
return "extended_multizone"
|
|
30
|
-
elif device.state.has_multizone:
|
|
31
|
-
return "multizone"
|
|
32
|
-
elif device.state.has_hev:
|
|
33
|
-
return "hev"
|
|
34
|
-
elif device.state.has_infrared:
|
|
35
|
-
return "infrared"
|
|
36
|
-
elif device.state.has_color:
|
|
37
|
-
return "color"
|
|
38
|
-
else:
|
|
39
|
-
return "basic"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class HierarchicalScenarioManager:
|
|
43
|
-
"""Manages scenarios across multiple scopes with precedence resolution.
|
|
44
|
-
|
|
45
|
-
Supports 5 scope levels with precedence (specific to general):
|
|
46
|
-
1. Device-specific (by serial) - Highest priority
|
|
47
|
-
2. Device-type specific (by capability: color, multizone, matrix, etc.)
|
|
48
|
-
3. Location-specific (all devices in location)
|
|
49
|
-
4. Group-specific (all devices in group)
|
|
50
|
-
5. Global (all emulated devices) - Lowest priority
|
|
51
|
-
|
|
52
|
-
When resolving scenarios for a device, configurations from all applicable
|
|
53
|
-
scopes are merged using union semantics for lists and override semantics
|
|
54
|
-
for scalars.
|
|
55
|
-
"""
|
|
56
|
-
|
|
57
|
-
def __init__(self):
|
|
58
|
-
# Scenario storage by scope
|
|
59
|
-
self.device_scenarios: dict[str, ScenarioConfig] = {} # serial → config
|
|
60
|
-
self.type_scenarios: dict[str, ScenarioConfig] = {} # type → config
|
|
61
|
-
self.location_scenarios: dict[str, ScenarioConfig] = {} # location → config
|
|
62
|
-
self.group_scenarios: dict[str, ScenarioConfig] = {} # group → config
|
|
63
|
-
self.global_scenario: ScenarioConfig = ScenarioConfig(
|
|
64
|
-
firmware_version=None, send_unhandled=False
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
def set_device_scenario(self, serial: str, config: ScenarioConfig):
|
|
68
|
-
"""Set scenario for specific device by serial."""
|
|
69
|
-
self.device_scenarios[serial] = config
|
|
70
|
-
|
|
71
|
-
def set_type_scenario(self, device_type: str, config: ScenarioConfig):
|
|
72
|
-
"""Set scenario for device type (color, multizone, matrix, etc.)."""
|
|
73
|
-
self.type_scenarios[device_type] = config
|
|
74
|
-
|
|
75
|
-
def set_location_scenario(self, location: str, config: ScenarioConfig):
|
|
76
|
-
"""Set scenario for all devices in a location."""
|
|
77
|
-
self.location_scenarios[location] = config
|
|
78
|
-
|
|
79
|
-
def set_group_scenario(self, group: str, config: ScenarioConfig):
|
|
80
|
-
"""Set scenario for all devices in a group."""
|
|
81
|
-
self.group_scenarios[group] = config
|
|
82
|
-
|
|
83
|
-
def set_global_scenario(self, config: ScenarioConfig):
|
|
84
|
-
"""Set global scenario for all devices."""
|
|
85
|
-
self.global_scenario = config
|
|
86
|
-
|
|
87
|
-
def delete_device_scenario(self, serial: str) -> bool:
|
|
88
|
-
"""Delete device-specific scenario. Returns True if existed."""
|
|
89
|
-
return self.device_scenarios.pop(serial, None) is not None
|
|
90
|
-
|
|
91
|
-
def delete_type_scenario(self, device_type: str) -> bool:
|
|
92
|
-
"""Delete type-specific scenario. Returns True if existed."""
|
|
93
|
-
return self.type_scenarios.pop(device_type, None) is not None
|
|
94
|
-
|
|
95
|
-
def delete_location_scenario(self, location: str) -> bool:
|
|
96
|
-
"""Delete location-specific scenario. Returns True if existed."""
|
|
97
|
-
return self.location_scenarios.pop(location, None) is not None
|
|
98
|
-
|
|
99
|
-
def delete_group_scenario(self, group: str) -> bool:
|
|
100
|
-
"""Delete group-specific scenario. Returns True if existed."""
|
|
101
|
-
return self.group_scenarios.pop(group, None) is not None
|
|
102
|
-
|
|
103
|
-
def clear_global_scenario(self):
|
|
104
|
-
"""Clear global scenario (reset to empty)."""
|
|
105
|
-
self.global_scenario = ScenarioConfig(
|
|
106
|
-
firmware_version=None, send_unhandled=False
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
def get_global_scenario(self) -> ScenarioConfig:
|
|
110
|
-
"""Get global scenario configuration."""
|
|
111
|
-
return self.global_scenario
|
|
112
|
-
|
|
113
|
-
def get_device_scenario(self, serial: str) -> ScenarioConfig | None:
|
|
114
|
-
"""Get device-specific scenario by serial.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
serial: Device serial number
|
|
118
|
-
|
|
119
|
-
Returns:
|
|
120
|
-
ScenarioConfig if scenario exists, None otherwise
|
|
121
|
-
"""
|
|
122
|
-
return self.device_scenarios.get(serial)
|
|
123
|
-
|
|
124
|
-
def get_type_scenario(self, device_type: str) -> ScenarioConfig | None:
|
|
125
|
-
"""Get type-specific scenario.
|
|
126
|
-
|
|
127
|
-
Args:
|
|
128
|
-
device_type: Device type (color, multizone, matrix, etc.)
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
ScenarioConfig if scenario exists, None otherwise
|
|
132
|
-
"""
|
|
133
|
-
return self.type_scenarios.get(device_type)
|
|
134
|
-
|
|
135
|
-
def get_location_scenario(self, location: str) -> ScenarioConfig | None:
|
|
136
|
-
"""Get location-specific scenario.
|
|
137
|
-
|
|
138
|
-
Args:
|
|
139
|
-
location: Device location label
|
|
140
|
-
|
|
141
|
-
Returns:
|
|
142
|
-
ScenarioConfig if scenario exists, None otherwise
|
|
143
|
-
"""
|
|
144
|
-
return self.location_scenarios.get(location)
|
|
145
|
-
|
|
146
|
-
def get_group_scenario(self, group: str) -> ScenarioConfig | None:
|
|
147
|
-
"""Get group-specific scenario.
|
|
148
|
-
|
|
149
|
-
Args:
|
|
150
|
-
group: Device group label
|
|
151
|
-
|
|
152
|
-
Returns:
|
|
153
|
-
ScenarioConfig if scenario exists, None otherwise
|
|
154
|
-
"""
|
|
155
|
-
return self.group_scenarios.get(group)
|
|
156
|
-
|
|
157
|
-
def get_scenario_for_device(
|
|
158
|
-
self,
|
|
159
|
-
serial: str,
|
|
160
|
-
device_type: str,
|
|
161
|
-
location: str,
|
|
162
|
-
group: str,
|
|
163
|
-
) -> ScenarioConfig:
|
|
164
|
-
"""Resolve scenario for device with precedence.
|
|
165
|
-
|
|
166
|
-
Precedence (highest to lowest):
|
|
167
|
-
1. Device-specific
|
|
168
|
-
2. Device-type
|
|
169
|
-
3. Location
|
|
170
|
-
4. Group
|
|
171
|
-
5. Global
|
|
172
|
-
|
|
173
|
-
Returns merged configuration with most specific values taking priority.
|
|
174
|
-
Lists are merged using union (all values combined).
|
|
175
|
-
Dicts are merged with later values overriding earlier.
|
|
176
|
-
Scalars use the most specific non-None value.
|
|
177
|
-
|
|
178
|
-
Args:
|
|
179
|
-
serial: Device serial number
|
|
180
|
-
device_type: Device type (color, multizone, matrix, etc.)
|
|
181
|
-
location: Device location label
|
|
182
|
-
group: Device group label
|
|
183
|
-
|
|
184
|
-
Returns:
|
|
185
|
-
Merged ScenarioConfig
|
|
186
|
-
"""
|
|
187
|
-
# Start with empty config
|
|
188
|
-
merged = ScenarioConfig(firmware_version=None, send_unhandled=False)
|
|
189
|
-
|
|
190
|
-
# Layer in each scope (general to specific)
|
|
191
|
-
# Later scopes override or merge with earlier ones
|
|
192
|
-
for config in [
|
|
193
|
-
self.global_scenario,
|
|
194
|
-
self.group_scenarios.get(group),
|
|
195
|
-
self.location_scenarios.get(location),
|
|
196
|
-
self.type_scenarios.get(device_type),
|
|
197
|
-
self.device_scenarios.get(serial),
|
|
198
|
-
]:
|
|
199
|
-
if config is None:
|
|
200
|
-
continue
|
|
201
|
-
|
|
202
|
-
# Merge drop_packets dict (later overwrites earlier)
|
|
203
|
-
merged.drop_packets.update(config.drop_packets)
|
|
204
|
-
|
|
205
|
-
# Merge lists using union (combine all values)
|
|
206
|
-
merged.malformed_packets = list(
|
|
207
|
-
set(merged.malformed_packets + config.malformed_packets)
|
|
208
|
-
)
|
|
209
|
-
merged.invalid_field_values = list(
|
|
210
|
-
set(merged.invalid_field_values + config.invalid_field_values)
|
|
211
|
-
)
|
|
212
|
-
merged.partial_responses = list(
|
|
213
|
-
set(merged.partial_responses + config.partial_responses)
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
# Merge delays dict (later overwrites earlier)
|
|
217
|
-
merged.response_delays.update(config.response_delays)
|
|
218
|
-
|
|
219
|
-
# Scalars: use most specific non-default value
|
|
220
|
-
if config.firmware_version is not None:
|
|
221
|
-
merged.firmware_version = config.firmware_version
|
|
222
|
-
if config.send_unhandled:
|
|
223
|
-
merged.send_unhandled = True
|
|
224
|
-
|
|
225
|
-
return merged
|
|
226
|
-
|
|
227
|
-
def should_respond(self, packet_type: int, scenario: ScenarioConfig) -> bool:
|
|
228
|
-
"""Check if device should respond to packet type.
|
|
229
|
-
|
|
230
|
-
Uses probabilistic dropping based on drop_packets configuration.
|
|
231
|
-
|
|
232
|
-
Args:
|
|
233
|
-
packet_type: LIFX packet type number
|
|
234
|
-
scenario: Resolved scenario configuration
|
|
235
|
-
|
|
236
|
-
Returns:
|
|
237
|
-
False if packet should be dropped, True otherwise
|
|
238
|
-
"""
|
|
239
|
-
if packet_type not in scenario.drop_packets:
|
|
240
|
-
return True
|
|
241
|
-
|
|
242
|
-
# Get drop rate for this packet type (0.1-1.0)
|
|
243
|
-
drop_rate = scenario.drop_packets[packet_type]
|
|
244
|
-
|
|
245
|
-
# Probabilistic drop: random value [0, 1) < drop_rate means drop
|
|
246
|
-
return random.random() >= drop_rate # nosec
|
|
247
|
-
|
|
248
|
-
def get_response_delay(self, packet_type: int, scenario: ScenarioConfig) -> float:
|
|
249
|
-
"""Get response delay for packet type.
|
|
250
|
-
|
|
251
|
-
Args:
|
|
252
|
-
packet_type: LIFX packet type number
|
|
253
|
-
scenario: Resolved scenario configuration
|
|
254
|
-
|
|
255
|
-
Returns:
|
|
256
|
-
Delay in seconds (0.0 if no delay configured)
|
|
257
|
-
"""
|
|
258
|
-
return scenario.response_delays.get(packet_type, 0.0)
|
|
259
|
-
|
|
260
|
-
def should_send_malformed(self, packet_type: int, scenario: ScenarioConfig) -> bool:
|
|
261
|
-
"""Check if response should be malformed/truncated.
|
|
262
|
-
|
|
263
|
-
Args:
|
|
264
|
-
packet_type: LIFX packet type number
|
|
265
|
-
scenario: Resolved scenario configuration
|
|
266
|
-
|
|
267
|
-
Returns:
|
|
268
|
-
True if response should be corrupted
|
|
269
|
-
"""
|
|
270
|
-
return packet_type in scenario.malformed_packets
|
|
271
|
-
|
|
272
|
-
def should_send_invalid_fields(
|
|
273
|
-
self, packet_type: int, scenario: ScenarioConfig
|
|
274
|
-
) -> bool:
|
|
275
|
-
"""Check if response should have invalid field values (all 0xFF).
|
|
276
|
-
|
|
277
|
-
Args:
|
|
278
|
-
packet_type: LIFX packet type number
|
|
279
|
-
scenario: Resolved scenario configuration
|
|
280
|
-
|
|
281
|
-
Returns:
|
|
282
|
-
True if response should have invalid fields
|
|
283
|
-
"""
|
|
284
|
-
return packet_type in scenario.invalid_field_values
|
|
285
|
-
|
|
286
|
-
def get_firmware_version_override(
|
|
287
|
-
self, scenario: ScenarioConfig
|
|
288
|
-
) -> tuple[int, int] | None:
|
|
289
|
-
"""Get firmware version override if configured.
|
|
290
|
-
|
|
291
|
-
Args:
|
|
292
|
-
scenario: Resolved scenario configuration
|
|
293
|
-
|
|
294
|
-
Returns:
|
|
295
|
-
(major, minor) tuple or None
|
|
296
|
-
"""
|
|
297
|
-
return scenario.firmware_version
|
|
298
|
-
|
|
299
|
-
def should_send_partial_response(
|
|
300
|
-
self, packet_type: int, scenario: ScenarioConfig
|
|
301
|
-
) -> bool:
|
|
302
|
-
"""Check if response should be partial (incomplete multizone/tile data).
|
|
303
|
-
|
|
304
|
-
Args:
|
|
305
|
-
packet_type: LIFX packet type number
|
|
306
|
-
scenario: Resolved scenario configuration
|
|
307
|
-
|
|
308
|
-
Returns:
|
|
309
|
-
True if response should be incomplete
|
|
310
|
-
"""
|
|
311
|
-
return packet_type in scenario.partial_responses
|
|
312
|
-
|
|
313
|
-
def should_send_unhandled(self, scenario: ScenarioConfig) -> bool:
|
|
314
|
-
"""Check if StateUnhandled should be sent for unknown packet types.
|
|
315
|
-
|
|
316
|
-
Args:
|
|
317
|
-
scenario: Resolved scenario configuration
|
|
318
|
-
|
|
319
|
-
Returns:
|
|
320
|
-
True if StateUnhandled should be sent
|
|
321
|
-
"""
|
|
322
|
-
return scenario.send_unhandled
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
"""Shared domain models for LIFX emulator.
|
|
2
|
-
|
|
3
|
-
This module contains Pydantic models that are used across multiple layers
|
|
4
|
-
of the application (domain, API, persistence, etc.).
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from pydantic import BaseModel, Field, field_validator
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ScenarioConfig(BaseModel):
|
|
11
|
-
"""Scenario configuration for testing LIFX protocol behavior.
|
|
12
|
-
|
|
13
|
-
Scenarios define testing behaviors for the emulator:
|
|
14
|
-
- Dropping specific packet types (no response)
|
|
15
|
-
- Adding response delays
|
|
16
|
-
- Sending malformed/corrupted responses
|
|
17
|
-
- Overriding firmware version
|
|
18
|
-
- Sending incomplete data
|
|
19
|
-
|
|
20
|
-
This is used by:
|
|
21
|
-
- HierarchicalScenarioManager (domain layer)
|
|
22
|
-
- API endpoints (API layer)
|
|
23
|
-
- ScenarioPersistence (persistence layer)
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
drop_packets: dict[int, float] = Field(
|
|
27
|
-
default_factory=dict,
|
|
28
|
-
description="Map of packet types to drop rates (0.0-1.0). "
|
|
29
|
-
"1.0 = always drop, 0.5 = drop 50%, 0.1 = drop 10%. "
|
|
30
|
-
"Example: {101: 1.0, 102: 0.6}",
|
|
31
|
-
)
|
|
32
|
-
response_delays: dict[int, float] = Field(
|
|
33
|
-
default_factory=dict,
|
|
34
|
-
description="Map of packet types to delay in seconds before responding",
|
|
35
|
-
)
|
|
36
|
-
malformed_packets: list[int] = Field(
|
|
37
|
-
default_factory=list,
|
|
38
|
-
description="List of packet types to send with truncated/corrupted payloads",
|
|
39
|
-
)
|
|
40
|
-
invalid_field_values: list[int] = Field(
|
|
41
|
-
default_factory=list,
|
|
42
|
-
description="List of packet types to send with all 0xFF bytes in fields",
|
|
43
|
-
)
|
|
44
|
-
firmware_version: tuple[int, int] | None = Field(
|
|
45
|
-
None, description="Override firmware version (major, minor). Example: [3, 70]"
|
|
46
|
-
)
|
|
47
|
-
partial_responses: list[int] = Field(
|
|
48
|
-
default_factory=list,
|
|
49
|
-
description="List of packet types to send with incomplete data",
|
|
50
|
-
)
|
|
51
|
-
send_unhandled: bool = Field(
|
|
52
|
-
False, description="Send unhandled message responses for unknown packet types"
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
@field_validator("drop_packets", mode="before")
|
|
56
|
-
@classmethod
|
|
57
|
-
def convert_drop_packets_keys(cls, v):
|
|
58
|
-
"""Convert string keys to integers for drop_packets.
|
|
59
|
-
|
|
60
|
-
This allows JSON serialization where keys must be strings,
|
|
61
|
-
but internally we use integer packet types.
|
|
62
|
-
"""
|
|
63
|
-
if isinstance(v, dict):
|
|
64
|
-
return {int(k): float(val) for k, val in v.items()}
|
|
65
|
-
return v
|
|
66
|
-
|
|
67
|
-
@field_validator("response_delays", mode="before")
|
|
68
|
-
@classmethod
|
|
69
|
-
def convert_response_delays_keys(cls, v):
|
|
70
|
-
"""Convert string keys to integers for response_delays.
|
|
71
|
-
|
|
72
|
-
This allows JSON serialization where keys must be strings,
|
|
73
|
-
but internally we use integer packet types.
|
|
74
|
-
"""
|
|
75
|
-
if isinstance(v, dict):
|
|
76
|
-
return {int(k): float(val) for k, val in v.items()}
|
|
77
|
-
return v
|
|
78
|
-
|
|
79
|
-
@classmethod
|
|
80
|
-
def from_dict(cls, data: dict) -> "ScenarioConfig":
|
|
81
|
-
"""Create from dictionary (backward compatibility wrapper).
|
|
82
|
-
|
|
83
|
-
Note: This wraps Pydantic's .model_validate() for backward compatibility.
|
|
84
|
-
The field_validators automatically handle string-to-int key conversion.
|
|
85
|
-
|
|
86
|
-
Args:
|
|
87
|
-
data: Dictionary with scenario configuration
|
|
88
|
-
|
|
89
|
-
Returns:
|
|
90
|
-
ScenarioConfig instance
|
|
91
|
-
"""
|
|
92
|
-
return cls.model_validate(data)
|
|
93
|
-
|
|
94
|
-
def to_dict(self) -> dict:
|
|
95
|
-
"""Convert to dictionary for JSON serialization.
|
|
96
|
-
|
|
97
|
-
Note: Pydantic models have .model_dump() which does this,
|
|
98
|
-
but we keep this method for backward compatibility with
|
|
99
|
-
existing code that expects string keys for packet types.
|
|
100
|
-
|
|
101
|
-
Returns:
|
|
102
|
-
Dictionary with string keys for drop_packets and response_delays
|
|
103
|
-
"""
|
|
104
|
-
return {
|
|
105
|
-
"drop_packets": {str(k): v for k, v in self.drop_packets.items()},
|
|
106
|
-
"response_delays": {str(k): v for k, v in self.response_delays.items()},
|
|
107
|
-
"malformed_packets": self.malformed_packets,
|
|
108
|
-
"invalid_field_values": self.invalid_field_values,
|
|
109
|
-
"firmware_version": self.firmware_version,
|
|
110
|
-
"partial_responses": self.partial_responses,
|
|
111
|
-
"send_unhandled": self.send_unhandled,
|
|
112
|
-
}
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
"""Async persistent storage for scenario configurations.
|
|
2
|
-
|
|
3
|
-
This module provides async JSON serialization and deserialization for scenarios,
|
|
4
|
-
allowing them to persist across emulator restarts without blocking the event loop.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import asyncio
|
|
10
|
-
import json
|
|
11
|
-
import logging
|
|
12
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from typing import Any
|
|
15
|
-
|
|
16
|
-
from lifx_emulator.scenarios.manager import HierarchicalScenarioManager, ScenarioConfig
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
|
|
20
|
-
DEFAULT_STORAGE_DIR = Path.home() / ".lifx-emulator"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class ScenarioPersistenceAsyncFile:
|
|
24
|
-
"""Async persistent storage for scenario configurations.
|
|
25
|
-
|
|
26
|
-
Non-blocking asynchronous I/O for scenario persistence.
|
|
27
|
-
Scenarios are stored in JSON format at ~/.lifx-emulator/scenarios.json
|
|
28
|
-
with separate sections for each scope level.
|
|
29
|
-
|
|
30
|
-
Features:
|
|
31
|
-
- Async I/O operations (no event loop blocking)
|
|
32
|
-
- Executor-based I/O for file operations
|
|
33
|
-
- Atomic writes (write to temp file, then rename)
|
|
34
|
-
- Graceful error handling and recovery
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
def __init__(self, storage_path: Path | str | None = None):
|
|
38
|
-
"""Initialize async scenario persistence.
|
|
39
|
-
|
|
40
|
-
Args:
|
|
41
|
-
storage_path: Directory to store scenarios.json
|
|
42
|
-
Defaults to ~/.lifx-emulator
|
|
43
|
-
"""
|
|
44
|
-
if storage_path is None:
|
|
45
|
-
storage_path = DEFAULT_STORAGE_DIR
|
|
46
|
-
|
|
47
|
-
self.storage_path = Path(storage_path)
|
|
48
|
-
self.scenario_file = self.storage_path / "scenarios.json"
|
|
49
|
-
|
|
50
|
-
# Single-thread executor for serialized I/O operations
|
|
51
|
-
self.executor = ThreadPoolExecutor(
|
|
52
|
-
max_workers=1, thread_name_prefix="scenario-io"
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
logger.debug("Async scenario storage initialized at %s", self.storage_path)
|
|
56
|
-
|
|
57
|
-
async def load(self) -> HierarchicalScenarioManager:
|
|
58
|
-
"""Load scenarios from disk (async).
|
|
59
|
-
|
|
60
|
-
Returns:
|
|
61
|
-
HierarchicalScenarioManager with loaded scenarios.
|
|
62
|
-
If file doesn't exist, returns empty manager.
|
|
63
|
-
"""
|
|
64
|
-
loop = asyncio.get_running_loop()
|
|
65
|
-
return await loop.run_in_executor(self.executor, self._sync_load)
|
|
66
|
-
|
|
67
|
-
def _sync_load(self) -> HierarchicalScenarioManager:
|
|
68
|
-
"""Synchronous load operation (runs in executor).
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
HierarchicalScenarioManager with loaded scenarios
|
|
72
|
-
"""
|
|
73
|
-
manager = HierarchicalScenarioManager()
|
|
74
|
-
|
|
75
|
-
if not self.scenario_file.exists():
|
|
76
|
-
logger.debug("No scenario file found at %s", self.scenario_file)
|
|
77
|
-
return manager
|
|
78
|
-
|
|
79
|
-
try:
|
|
80
|
-
with open(self.scenario_file) as f:
|
|
81
|
-
data = json.load(f)
|
|
82
|
-
|
|
83
|
-
# Load global scenario
|
|
84
|
-
if "global" in data and data["global"]:
|
|
85
|
-
manager.global_scenario = ScenarioConfig.model_validate(data["global"])
|
|
86
|
-
logger.debug("Loaded global scenario")
|
|
87
|
-
|
|
88
|
-
# Load device-specific scenarios
|
|
89
|
-
for serial, config_data in data.get("devices", {}).items():
|
|
90
|
-
manager.device_scenarios[serial] = ScenarioConfig.model_validate(
|
|
91
|
-
config_data
|
|
92
|
-
)
|
|
93
|
-
if manager.device_scenarios:
|
|
94
|
-
logger.debug(
|
|
95
|
-
"Loaded %s device scenario(s)", len(manager.device_scenarios)
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
# Load type-specific scenarios
|
|
99
|
-
for device_type, config_data in data.get("types", {}).items():
|
|
100
|
-
manager.type_scenarios[device_type] = ScenarioConfig.model_validate(
|
|
101
|
-
config_data
|
|
102
|
-
)
|
|
103
|
-
if manager.type_scenarios:
|
|
104
|
-
logger.debug("Loaded %s type scenario(s)", len(manager.type_scenarios))
|
|
105
|
-
|
|
106
|
-
# Load location-specific scenarios
|
|
107
|
-
for location, config_data in data.get("locations", {}).items():
|
|
108
|
-
manager.location_scenarios[location] = ScenarioConfig.model_validate(
|
|
109
|
-
config_data
|
|
110
|
-
)
|
|
111
|
-
if manager.location_scenarios:
|
|
112
|
-
logger.debug(
|
|
113
|
-
"Loaded %s location scenario(s)", len(manager.location_scenarios)
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
# Load group-specific scenarios
|
|
117
|
-
for group, config_data in data.get("groups", {}).items():
|
|
118
|
-
manager.group_scenarios[group] = ScenarioConfig.model_validate(
|
|
119
|
-
config_data
|
|
120
|
-
)
|
|
121
|
-
if manager.group_scenarios:
|
|
122
|
-
logger.debug(
|
|
123
|
-
"Loaded %s group scenario(s)", len(manager.group_scenarios)
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
logger.info("Loaded scenarios from %s", self.scenario_file)
|
|
127
|
-
return manager
|
|
128
|
-
|
|
129
|
-
except json.JSONDecodeError as e:
|
|
130
|
-
logger.error("Failed to parse scenario file %s: %s", self.scenario_file, e)
|
|
131
|
-
return manager
|
|
132
|
-
except Exception as e:
|
|
133
|
-
logger.error("Failed to load scenarios from %s: %s", self.scenario_file, e)
|
|
134
|
-
return manager
|
|
135
|
-
|
|
136
|
-
async def save(self, manager: HierarchicalScenarioManager) -> None:
|
|
137
|
-
"""Save scenarios to disk (async).
|
|
138
|
-
|
|
139
|
-
Args:
|
|
140
|
-
manager: HierarchicalScenarioManager to save
|
|
141
|
-
"""
|
|
142
|
-
loop = asyncio.get_running_loop()
|
|
143
|
-
await loop.run_in_executor(self.executor, self._sync_save, manager)
|
|
144
|
-
|
|
145
|
-
def _sync_save(self, manager: HierarchicalScenarioManager) -> None:
|
|
146
|
-
"""Synchronous save operation (runs in executor).
|
|
147
|
-
|
|
148
|
-
Args:
|
|
149
|
-
manager: HierarchicalScenarioManager to save
|
|
150
|
-
"""
|
|
151
|
-
|
|
152
|
-
# Convert response_delays keys to strings for JSON serialization
|
|
153
|
-
def _serialize_config(config: ScenarioConfig) -> dict[str, Any]:
|
|
154
|
-
"""Convert ScenarioConfig to JSON-serializable dict."""
|
|
155
|
-
data = config.to_dict()
|
|
156
|
-
# Convert int keys in response_delays to strings
|
|
157
|
-
if data.get("response_delays"):
|
|
158
|
-
data["response_delays"] = {
|
|
159
|
-
str(k): v for k, v in data["response_delays"].items()
|
|
160
|
-
}
|
|
161
|
-
return data
|
|
162
|
-
|
|
163
|
-
data: dict[str, Any] = {
|
|
164
|
-
"global": _serialize_config(manager.global_scenario),
|
|
165
|
-
"devices": {
|
|
166
|
-
serial: _serialize_config(config)
|
|
167
|
-
for serial, config in manager.device_scenarios.items()
|
|
168
|
-
},
|
|
169
|
-
"types": {
|
|
170
|
-
device_type: _serialize_config(config)
|
|
171
|
-
for device_type, config in manager.type_scenarios.items()
|
|
172
|
-
},
|
|
173
|
-
"locations": {
|
|
174
|
-
location: _serialize_config(config)
|
|
175
|
-
for location, config in manager.location_scenarios.items()
|
|
176
|
-
},
|
|
177
|
-
"groups": {
|
|
178
|
-
group: _serialize_config(config)
|
|
179
|
-
for group, config in manager.group_scenarios.items()
|
|
180
|
-
},
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
try:
|
|
184
|
-
# Create directory if it doesn't exist
|
|
185
|
-
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
186
|
-
|
|
187
|
-
# Write to temporary file first, then rename (atomic operation)
|
|
188
|
-
temp_file = self.scenario_file.with_suffix(".json.tmp")
|
|
189
|
-
with open(temp_file, "w") as f:
|
|
190
|
-
json.dump(data, f, indent=2)
|
|
191
|
-
|
|
192
|
-
# Atomic rename
|
|
193
|
-
temp_file.replace(self.scenario_file)
|
|
194
|
-
|
|
195
|
-
logger.info("Saved scenarios to %s", self.scenario_file)
|
|
196
|
-
|
|
197
|
-
except Exception as e:
|
|
198
|
-
logger.error("Failed to save scenarios to %s: %s", self.scenario_file, e)
|
|
199
|
-
raise
|
|
200
|
-
|
|
201
|
-
async def delete(self) -> bool:
|
|
202
|
-
"""Delete the scenario file (async).
|
|
203
|
-
|
|
204
|
-
Returns:
|
|
205
|
-
True if file was deleted, False if it didn't exist
|
|
206
|
-
"""
|
|
207
|
-
loop = asyncio.get_running_loop()
|
|
208
|
-
return await loop.run_in_executor(self.executor, self._sync_delete)
|
|
209
|
-
|
|
210
|
-
def _sync_delete(self) -> bool:
|
|
211
|
-
"""Synchronous delete operation (runs in executor).
|
|
212
|
-
|
|
213
|
-
Returns:
|
|
214
|
-
True if file was deleted, False if it didn't exist
|
|
215
|
-
"""
|
|
216
|
-
if self.scenario_file.exists():
|
|
217
|
-
try:
|
|
218
|
-
self.scenario_file.unlink()
|
|
219
|
-
logger.info("Deleted scenario file %s", self.scenario_file)
|
|
220
|
-
return True
|
|
221
|
-
except Exception as e:
|
|
222
|
-
logger.error("Failed to delete scenario file: %s", e)
|
|
223
|
-
raise
|
|
224
|
-
return False
|
|
225
|
-
|
|
226
|
-
async def shutdown(self) -> None:
|
|
227
|
-
"""Gracefully shutdown executor.
|
|
228
|
-
|
|
229
|
-
This should be called before the application exits.
|
|
230
|
-
"""
|
|
231
|
-
logger.info("Shutting down async scenario storage...")
|
|
232
|
-
|
|
233
|
-
# Shutdown executor (non-blocking to avoid hanging on Windows)
|
|
234
|
-
loop = asyncio.get_running_loop()
|
|
235
|
-
await loop.run_in_executor(None, self.executor.shutdown, True)
|
|
236
|
-
|
|
237
|
-
logger.info("Async scenario storage shutdown complete")
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
# Note: Pydantic's field_validators in ScenarioConfig handle string-to-int
|
|
241
|
-
# key conversion automatically, so no additional deserialization logic is needed.
|