lifx-emulator 1.0.2__py3-none-any.whl → 2.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 +1 -1
- lifx_emulator/__main__.py +26 -51
- lifx_emulator/api/__init__.py +18 -0
- lifx_emulator/api/app.py +154 -0
- lifx_emulator/api/mappers/__init__.py +5 -0
- lifx_emulator/api/mappers/device_mapper.py +114 -0
- lifx_emulator/api/models.py +133 -0
- lifx_emulator/api/routers/__init__.py +11 -0
- lifx_emulator/api/routers/devices.py +130 -0
- lifx_emulator/api/routers/monitoring.py +52 -0
- lifx_emulator/api/routers/scenarios.py +247 -0
- lifx_emulator/api/services/__init__.py +8 -0
- lifx_emulator/api/services/device_service.py +198 -0
- lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
- lifx_emulator/devices/__init__.py +37 -0
- lifx_emulator/devices/device.py +333 -0
- lifx_emulator/devices/manager.py +256 -0
- lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
- lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
- lifx_emulator/devices/states.py +333 -0
- lifx_emulator/factories/__init__.py +37 -0
- lifx_emulator/factories/builder.py +371 -0
- lifx_emulator/factories/default_config.py +158 -0
- lifx_emulator/factories/factory.py +221 -0
- lifx_emulator/factories/firmware_config.py +59 -0
- lifx_emulator/factories/serial_generator.py +82 -0
- lifx_emulator/handlers/base.py +1 -1
- lifx_emulator/handlers/device_handlers.py +10 -28
- lifx_emulator/handlers/light_handlers.py +5 -9
- lifx_emulator/handlers/multizone_handlers.py +1 -1
- lifx_emulator/handlers/tile_handlers.py +1 -1
- lifx_emulator/products/generator.py +389 -170
- lifx_emulator/products/registry.py +52 -40
- lifx_emulator/products/specs.py +12 -13
- lifx_emulator/protocol/base.py +115 -61
- lifx_emulator/protocol/generator.py +18 -5
- lifx_emulator/protocol/packets.py +7 -7
- lifx_emulator/repositories/__init__.py +22 -0
- lifx_emulator/repositories/device_repository.py +155 -0
- lifx_emulator/repositories/storage_backend.py +107 -0
- lifx_emulator/scenarios/__init__.py +22 -0
- lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
- lifx_emulator/scenarios/models.py +112 -0
- lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
- lifx_emulator/server.py +38 -64
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/METADATA +1 -1
- lifx_emulator-2.0.0.dist-info/RECORD +62 -0
- lifx_emulator/device.py +0 -750
- lifx_emulator/device_states.py +0 -114
- lifx_emulator/factories.py +0 -380
- lifx_emulator/storage_protocol.py +0 -100
- lifx_emulator-1.0.2.dist-info/RECORD +0 -40
- /lifx_emulator/{observers.py → devices/observers.py} +0 -0
- /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/entry_points.txt +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Storage backend interfaces for device and scenario persistence.
|
|
2
|
+
|
|
3
|
+
Provides abstraction for persistent storage operations,
|
|
4
|
+
following the Repository Pattern and Dependency Inversion Principle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from lifx_emulator.scenarios import HierarchicalScenarioManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class IDeviceStorageBackend(Protocol):
|
|
17
|
+
"""Interface for device state persistence operations.
|
|
18
|
+
|
|
19
|
+
This protocol defines the contract for loading and saving device state.
|
|
20
|
+
Concrete implementations can use async file I/O, databases,
|
|
21
|
+
or other storage backends.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
async def save_device_state(self, device_state: Any) -> None:
|
|
25
|
+
"""Save device state to persistent storage (async).
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
device_state: DeviceState instance to persist
|
|
29
|
+
"""
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
def load_device_state(self, serial: str) -> dict | None:
|
|
33
|
+
"""Load device state from persistent storage (sync).
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
serial: Device serial number (12-character hex string)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dictionary with device state data, or None if not found
|
|
40
|
+
"""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def delete_device_state(self, serial: str) -> bool:
|
|
44
|
+
"""Delete device state from persistent storage.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
serial: Device serial number
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if state was deleted, False if not found
|
|
51
|
+
"""
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def list_devices(self) -> list[str]:
|
|
55
|
+
"""List all device serials with saved state.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of serial numbers
|
|
59
|
+
"""
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def delete_all_device_states(self) -> int:
|
|
63
|
+
"""Delete all device states from persistent storage.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Number of device states deleted
|
|
67
|
+
"""
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
async def shutdown(self) -> None:
|
|
71
|
+
"""Gracefully shutdown storage backend, flushing pending writes."""
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@runtime_checkable
|
|
76
|
+
class IScenarioStorageBackend(Protocol):
|
|
77
|
+
"""Interface for scenario configuration persistence operations.
|
|
78
|
+
|
|
79
|
+
This protocol defines the contract for loading and saving scenario configurations.
|
|
80
|
+
Concrete implementations can use async file I/O, databases,
|
|
81
|
+
or other storage backends.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
async def load(self) -> HierarchicalScenarioManager:
|
|
85
|
+
"""Load scenario configuration from persistent storage (async).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Scenario manager with loaded configuration, or default manager
|
|
89
|
+
if no saved data
|
|
90
|
+
"""
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
|
|
93
|
+
async def save(self, manager: HierarchicalScenarioManager) -> None:
|
|
94
|
+
"""Save scenario configuration to persistent storage (async).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
manager: Scenario manager whose configuration should be saved
|
|
98
|
+
"""
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
async def delete(self) -> bool:
|
|
102
|
+
"""Delete scenario configuration from persistent storage (async).
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True if configuration was deleted, False if it didn't exist
|
|
106
|
+
"""
|
|
107
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Scenario management module for LIFX emulator.
|
|
2
|
+
|
|
3
|
+
This module contains all scenario-related functionality including:
|
|
4
|
+
- Scenario manager (HierarchicalScenarioManager)
|
|
5
|
+
- Scenario models (ScenarioConfig)
|
|
6
|
+
- Scenario persistence (async file storage)
|
|
7
|
+
- Device type classification (get_device_type)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from lifx_emulator.scenarios.manager import (
|
|
11
|
+
HierarchicalScenarioManager,
|
|
12
|
+
get_device_type,
|
|
13
|
+
)
|
|
14
|
+
from lifx_emulator.scenarios.models import ScenarioConfig
|
|
15
|
+
from lifx_emulator.scenarios.persistence import ScenarioPersistenceAsyncFile
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"HierarchicalScenarioManager",
|
|
19
|
+
"ScenarioConfig",
|
|
20
|
+
"ScenarioPersistenceAsyncFile",
|
|
21
|
+
"get_device_type",
|
|
22
|
+
]
|
|
@@ -5,96 +5,12 @@ test scenarios at multiple scopes with precedence-based resolution.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import random
|
|
8
|
-
from
|
|
9
|
-
from typing import TYPE_CHECKING, Any
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
10
9
|
|
|
11
|
-
|
|
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
|
-
"""
|
|
10
|
+
from lifx_emulator.scenarios.models import ScenarioConfig
|
|
35
11
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
)
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from lifx_emulator.devices import EmulatedLifxDevice
|
|
98
14
|
|
|
99
15
|
|
|
100
16
|
def get_device_type(device: "EmulatedLifxDevice") -> str:
|
|
@@ -144,7 +60,9 @@ class HierarchicalScenarioManager:
|
|
|
144
60
|
self.type_scenarios: dict[str, ScenarioConfig] = {} # type → config
|
|
145
61
|
self.location_scenarios: dict[str, ScenarioConfig] = {} # location → config
|
|
146
62
|
self.group_scenarios: dict[str, ScenarioConfig] = {} # group → config
|
|
147
|
-
self.global_scenario: ScenarioConfig = ScenarioConfig(
|
|
63
|
+
self.global_scenario: ScenarioConfig = ScenarioConfig(
|
|
64
|
+
firmware_version=None, send_unhandled=False
|
|
65
|
+
)
|
|
148
66
|
|
|
149
67
|
def set_device_scenario(self, serial: str, config: ScenarioConfig):
|
|
150
68
|
"""Set scenario for specific device by serial."""
|
|
@@ -184,7 +102,9 @@ class HierarchicalScenarioManager:
|
|
|
184
102
|
|
|
185
103
|
def clear_global_scenario(self):
|
|
186
104
|
"""Clear global scenario (reset to empty)."""
|
|
187
|
-
self.global_scenario = ScenarioConfig(
|
|
105
|
+
self.global_scenario = ScenarioConfig(
|
|
106
|
+
firmware_version=None, send_unhandled=False
|
|
107
|
+
)
|
|
188
108
|
|
|
189
109
|
def get_global_scenario(self) -> ScenarioConfig:
|
|
190
110
|
"""Get global scenario configuration."""
|
|
@@ -265,7 +185,7 @@ class HierarchicalScenarioManager:
|
|
|
265
185
|
Merged ScenarioConfig
|
|
266
186
|
"""
|
|
267
187
|
# Start with empty config
|
|
268
|
-
merged = ScenarioConfig()
|
|
188
|
+
merged = ScenarioConfig(firmware_version=None, send_unhandled=False)
|
|
269
189
|
|
|
270
190
|
# Layer in each scope (general to specific)
|
|
271
191
|
# Later scopes override or merge with earlier ones
|
|
@@ -0,0 +1,112 @@
|
|
|
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,46 +1,75 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Async persistent storage for scenario configurations.
|
|
2
2
|
|
|
3
|
-
This module provides JSON serialization and deserialization for scenarios,
|
|
4
|
-
allowing them to persist across emulator restarts.
|
|
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
5
|
"""
|
|
6
6
|
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
7
10
|
import json
|
|
8
11
|
import logging
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
13
|
from pathlib import Path
|
|
10
14
|
from typing import Any
|
|
11
15
|
|
|
12
|
-
from lifx_emulator.
|
|
16
|
+
from lifx_emulator.scenarios.manager import HierarchicalScenarioManager, ScenarioConfig
|
|
13
17
|
|
|
14
18
|
logger = logging.getLogger(__name__)
|
|
15
19
|
|
|
20
|
+
DEFAULT_STORAGE_DIR = Path.home() / ".lifx-emulator"
|
|
21
|
+
|
|
16
22
|
|
|
17
|
-
class
|
|
18
|
-
"""
|
|
23
|
+
class ScenarioPersistenceAsyncFile:
|
|
24
|
+
"""Async persistent storage for scenario configurations.
|
|
19
25
|
|
|
26
|
+
Non-blocking asynchronous I/O for scenario persistence.
|
|
20
27
|
Scenarios are stored in JSON format at ~/.lifx-emulator/scenarios.json
|
|
21
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
|
|
22
35
|
"""
|
|
23
36
|
|
|
24
|
-
def __init__(self, storage_path: Path | None = None):
|
|
25
|
-
"""Initialize scenario persistence.
|
|
37
|
+
def __init__(self, storage_path: Path | str | None = None):
|
|
38
|
+
"""Initialize async scenario persistence.
|
|
26
39
|
|
|
27
40
|
Args:
|
|
28
41
|
storage_path: Directory to store scenarios.json
|
|
29
42
|
Defaults to ~/.lifx-emulator
|
|
30
43
|
"""
|
|
31
44
|
if storage_path is None:
|
|
32
|
-
storage_path =
|
|
45
|
+
storage_path = DEFAULT_STORAGE_DIR
|
|
33
46
|
|
|
34
47
|
self.storage_path = Path(storage_path)
|
|
35
48
|
self.scenario_file = self.storage_path / "scenarios.json"
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
|
|
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).
|
|
39
59
|
|
|
40
60
|
Returns:
|
|
41
61
|
HierarchicalScenarioManager with loaded scenarios.
|
|
42
62
|
If file doesn't exist, returns empty manager.
|
|
43
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
|
+
"""
|
|
44
73
|
manager = HierarchicalScenarioManager()
|
|
45
74
|
|
|
46
75
|
if not self.scenario_file.exists():
|
|
@@ -53,12 +82,14 @@ class ScenarioPersistence:
|
|
|
53
82
|
|
|
54
83
|
# Load global scenario
|
|
55
84
|
if "global" in data and data["global"]:
|
|
56
|
-
manager.global_scenario = ScenarioConfig.
|
|
85
|
+
manager.global_scenario = ScenarioConfig.model_validate(data["global"])
|
|
57
86
|
logger.debug("Loaded global scenario")
|
|
58
87
|
|
|
59
88
|
# Load device-specific scenarios
|
|
60
89
|
for serial, config_data in data.get("devices", {}).items():
|
|
61
|
-
manager.device_scenarios[serial] = ScenarioConfig.
|
|
90
|
+
manager.device_scenarios[serial] = ScenarioConfig.model_validate(
|
|
91
|
+
config_data
|
|
92
|
+
)
|
|
62
93
|
if manager.device_scenarios:
|
|
63
94
|
logger.debug(
|
|
64
95
|
"Loaded %s device scenario(s)", len(manager.device_scenarios)
|
|
@@ -66,7 +97,7 @@ class ScenarioPersistence:
|
|
|
66
97
|
|
|
67
98
|
# Load type-specific scenarios
|
|
68
99
|
for device_type, config_data in data.get("types", {}).items():
|
|
69
|
-
manager.type_scenarios[device_type] = ScenarioConfig.
|
|
100
|
+
manager.type_scenarios[device_type] = ScenarioConfig.model_validate(
|
|
70
101
|
config_data
|
|
71
102
|
)
|
|
72
103
|
if manager.type_scenarios:
|
|
@@ -74,7 +105,7 @@ class ScenarioPersistence:
|
|
|
74
105
|
|
|
75
106
|
# Load location-specific scenarios
|
|
76
107
|
for location, config_data in data.get("locations", {}).items():
|
|
77
|
-
manager.location_scenarios[location] = ScenarioConfig.
|
|
108
|
+
manager.location_scenarios[location] = ScenarioConfig.model_validate(
|
|
78
109
|
config_data
|
|
79
110
|
)
|
|
80
111
|
if manager.location_scenarios:
|
|
@@ -84,7 +115,9 @@ class ScenarioPersistence:
|
|
|
84
115
|
|
|
85
116
|
# Load group-specific scenarios
|
|
86
117
|
for group, config_data in data.get("groups", {}).items():
|
|
87
|
-
manager.group_scenarios[group] = ScenarioConfig.
|
|
118
|
+
manager.group_scenarios[group] = ScenarioConfig.model_validate(
|
|
119
|
+
config_data
|
|
120
|
+
)
|
|
88
121
|
if manager.group_scenarios:
|
|
89
122
|
logger.debug(
|
|
90
123
|
"Loaded %s group scenario(s)", len(manager.group_scenarios)
|
|
@@ -100,8 +133,17 @@ class ScenarioPersistence:
|
|
|
100
133
|
logger.error("Failed to load scenarios from %s: %s", self.scenario_file, e)
|
|
101
134
|
return manager
|
|
102
135
|
|
|
103
|
-
def save(self, manager: HierarchicalScenarioManager) -> None:
|
|
104
|
-
"""Save scenarios to disk.
|
|
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).
|
|
105
147
|
|
|
106
148
|
Args:
|
|
107
149
|
manager: HierarchicalScenarioManager to save
|
|
@@ -156,8 +198,17 @@ class ScenarioPersistence:
|
|
|
156
198
|
logger.error("Failed to save scenarios to %s: %s", self.scenario_file, e)
|
|
157
199
|
raise
|
|
158
200
|
|
|
159
|
-
def delete(self) -> bool:
|
|
160
|
-
"""Delete the scenario file.
|
|
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).
|
|
161
212
|
|
|
162
213
|
Returns:
|
|
163
214
|
True if file was deleted, False if it didn't exist
|
|
@@ -172,35 +223,19 @@ class ScenarioPersistence:
|
|
|
172
223
|
raise
|
|
173
224
|
return False
|
|
174
225
|
|
|
226
|
+
async def shutdown(self) -> None:
|
|
227
|
+
"""Gracefully shutdown executor.
|
|
175
228
|
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
229
|
+
This should be called before the application exits.
|
|
230
|
+
"""
|
|
231
|
+
logger.info("Shutting down async scenario storage...")
|
|
194
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)
|
|
195
236
|
|
|
196
|
-
|
|
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)
|
|
237
|
+
logger.info("Async scenario storage shutdown complete")
|
|
204
238
|
|
|
205
239
|
|
|
206
|
-
|
|
240
|
+
# Note: Pydantic's field_validators in ScenarioConfig handle string-to-int
|
|
241
|
+
# key conversion automatically, so no additional deserialization logic is needed.
|