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.
Files changed (40) hide show
  1. lifx_emulator/__init__.py +31 -0
  2. lifx_emulator/__main__.py +607 -0
  3. lifx_emulator/api.py +1825 -0
  4. lifx_emulator/async_storage.py +308 -0
  5. lifx_emulator/constants.py +33 -0
  6. lifx_emulator/device.py +750 -0
  7. lifx_emulator/device_states.py +114 -0
  8. lifx_emulator/factories.py +380 -0
  9. lifx_emulator/handlers/__init__.py +39 -0
  10. lifx_emulator/handlers/base.py +49 -0
  11. lifx_emulator/handlers/device_handlers.py +340 -0
  12. lifx_emulator/handlers/light_handlers.py +372 -0
  13. lifx_emulator/handlers/multizone_handlers.py +249 -0
  14. lifx_emulator/handlers/registry.py +110 -0
  15. lifx_emulator/handlers/tile_handlers.py +309 -0
  16. lifx_emulator/observers.py +139 -0
  17. lifx_emulator/products/__init__.py +28 -0
  18. lifx_emulator/products/generator.py +771 -0
  19. lifx_emulator/products/registry.py +1446 -0
  20. lifx_emulator/products/specs.py +242 -0
  21. lifx_emulator/products/specs.yml +327 -0
  22. lifx_emulator/protocol/__init__.py +1 -0
  23. lifx_emulator/protocol/base.py +334 -0
  24. lifx_emulator/protocol/const.py +8 -0
  25. lifx_emulator/protocol/generator.py +1371 -0
  26. lifx_emulator/protocol/header.py +159 -0
  27. lifx_emulator/protocol/packets.py +1351 -0
  28. lifx_emulator/protocol/protocol_types.py +844 -0
  29. lifx_emulator/protocol/serializer.py +379 -0
  30. lifx_emulator/scenario_manager.py +402 -0
  31. lifx_emulator/scenario_persistence.py +206 -0
  32. lifx_emulator/server.py +482 -0
  33. lifx_emulator/state_restorer.py +259 -0
  34. lifx_emulator/state_serializer.py +130 -0
  35. lifx_emulator/storage_protocol.py +100 -0
  36. lifx_emulator-1.0.0.dist-info/METADATA +445 -0
  37. lifx_emulator-1.0.0.dist-info/RECORD +40 -0
  38. lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
  39. lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
  40. 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