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.
Files changed (68) hide show
  1. lifx_emulator-3.0.1.dist-info/METADATA +102 -0
  2. lifx_emulator-3.0.1.dist-info/RECORD +18 -0
  3. lifx_emulator-3.0.1.dist-info/entry_points.txt +2 -0
  4. lifx_emulator_app/__init__.py +10 -0
  5. {lifx_emulator → lifx_emulator_app}/__main__.py +13 -5
  6. {lifx_emulator → lifx_emulator_app}/api/__init__.py +1 -1
  7. {lifx_emulator → lifx_emulator_app}/api/app.py +3 -3
  8. {lifx_emulator → lifx_emulator_app}/api/mappers/__init__.py +1 -1
  9. {lifx_emulator → lifx_emulator_app}/api/mappers/device_mapper.py +1 -1
  10. {lifx_emulator → lifx_emulator_app}/api/models.py +1 -2
  11. lifx_emulator_app/api/routers/__init__.py +11 -0
  12. {lifx_emulator → lifx_emulator_app}/api/routers/devices.py +2 -2
  13. {lifx_emulator → lifx_emulator_app}/api/routers/monitoring.py +1 -1
  14. {lifx_emulator → lifx_emulator_app}/api/routers/scenarios.py +1 -1
  15. lifx_emulator_app/api/services/__init__.py +8 -0
  16. {lifx_emulator → lifx_emulator_app}/api/services/device_service.py +3 -2
  17. lifx_emulator/__init__.py +0 -31
  18. lifx_emulator/api/routers/__init__.py +0 -11
  19. lifx_emulator/api/services/__init__.py +0 -8
  20. lifx_emulator/constants.py +0 -33
  21. lifx_emulator/devices/__init__.py +0 -37
  22. lifx_emulator/devices/device.py +0 -339
  23. lifx_emulator/devices/manager.py +0 -256
  24. lifx_emulator/devices/observers.py +0 -139
  25. lifx_emulator/devices/persistence.py +0 -308
  26. lifx_emulator/devices/state_restorer.py +0 -259
  27. lifx_emulator/devices/state_serializer.py +0 -157
  28. lifx_emulator/devices/states.py +0 -377
  29. lifx_emulator/factories/__init__.py +0 -37
  30. lifx_emulator/factories/builder.py +0 -373
  31. lifx_emulator/factories/default_config.py +0 -158
  32. lifx_emulator/factories/factory.py +0 -221
  33. lifx_emulator/factories/firmware_config.py +0 -77
  34. lifx_emulator/factories/serial_generator.py +0 -82
  35. lifx_emulator/handlers/__init__.py +0 -39
  36. lifx_emulator/handlers/base.py +0 -49
  37. lifx_emulator/handlers/device_handlers.py +0 -322
  38. lifx_emulator/handlers/light_handlers.py +0 -503
  39. lifx_emulator/handlers/multizone_handlers.py +0 -249
  40. lifx_emulator/handlers/registry.py +0 -110
  41. lifx_emulator/handlers/tile_handlers.py +0 -488
  42. lifx_emulator/products/__init__.py +0 -28
  43. lifx_emulator/products/generator.py +0 -1037
  44. lifx_emulator/products/registry.py +0 -1496
  45. lifx_emulator/products/specs.py +0 -284
  46. lifx_emulator/products/specs.yml +0 -352
  47. lifx_emulator/protocol/__init__.py +0 -1
  48. lifx_emulator/protocol/base.py +0 -446
  49. lifx_emulator/protocol/const.py +0 -8
  50. lifx_emulator/protocol/generator.py +0 -1384
  51. lifx_emulator/protocol/header.py +0 -159
  52. lifx_emulator/protocol/packets.py +0 -1351
  53. lifx_emulator/protocol/protocol_types.py +0 -817
  54. lifx_emulator/protocol/serializer.py +0 -379
  55. lifx_emulator/repositories/__init__.py +0 -22
  56. lifx_emulator/repositories/device_repository.py +0 -155
  57. lifx_emulator/repositories/storage_backend.py +0 -107
  58. lifx_emulator/scenarios/__init__.py +0 -22
  59. lifx_emulator/scenarios/manager.py +0 -322
  60. lifx_emulator/scenarios/models.py +0 -112
  61. lifx_emulator/scenarios/persistence.py +0 -241
  62. lifx_emulator/server.py +0 -464
  63. lifx_emulator-2.3.1.dist-info/METADATA +0 -107
  64. lifx_emulator-2.3.1.dist-info/RECORD +0 -62
  65. lifx_emulator-2.3.1.dist-info/entry_points.txt +0 -2
  66. lifx_emulator-2.3.1.dist-info/licenses/LICENSE +0 -35
  67. {lifx_emulator-2.3.1.dist-info → lifx_emulator-3.0.1.dist-info}/WHEEL +0 -0
  68. {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.