lifx-emulator 1.0.2__py3-none-any.whl → 2.1.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 (58) hide show
  1. lifx_emulator/__init__.py +1 -1
  2. lifx_emulator/__main__.py +26 -51
  3. lifx_emulator/api/__init__.py +18 -0
  4. lifx_emulator/api/app.py +154 -0
  5. lifx_emulator/api/mappers/__init__.py +5 -0
  6. lifx_emulator/api/mappers/device_mapper.py +114 -0
  7. lifx_emulator/api/models.py +133 -0
  8. lifx_emulator/api/routers/__init__.py +11 -0
  9. lifx_emulator/api/routers/devices.py +130 -0
  10. lifx_emulator/api/routers/monitoring.py +52 -0
  11. lifx_emulator/api/routers/scenarios.py +247 -0
  12. lifx_emulator/api/services/__init__.py +8 -0
  13. lifx_emulator/api/services/device_service.py +198 -0
  14. lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
  15. lifx_emulator/devices/__init__.py +37 -0
  16. lifx_emulator/devices/device.py +333 -0
  17. lifx_emulator/devices/manager.py +256 -0
  18. lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
  19. lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
  20. lifx_emulator/devices/states.py +346 -0
  21. lifx_emulator/factories/__init__.py +37 -0
  22. lifx_emulator/factories/builder.py +371 -0
  23. lifx_emulator/factories/default_config.py +158 -0
  24. lifx_emulator/factories/factory.py +221 -0
  25. lifx_emulator/factories/firmware_config.py +59 -0
  26. lifx_emulator/factories/serial_generator.py +82 -0
  27. lifx_emulator/handlers/base.py +1 -1
  28. lifx_emulator/handlers/device_handlers.py +10 -28
  29. lifx_emulator/handlers/light_handlers.py +5 -9
  30. lifx_emulator/handlers/multizone_handlers.py +1 -1
  31. lifx_emulator/handlers/tile_handlers.py +31 -11
  32. lifx_emulator/products/generator.py +389 -170
  33. lifx_emulator/products/registry.py +52 -40
  34. lifx_emulator/products/specs.py +12 -13
  35. lifx_emulator/protocol/base.py +175 -63
  36. lifx_emulator/protocol/generator.py +18 -5
  37. lifx_emulator/protocol/packets.py +7 -7
  38. lifx_emulator/protocol/protocol_types.py +35 -62
  39. lifx_emulator/repositories/__init__.py +22 -0
  40. lifx_emulator/repositories/device_repository.py +155 -0
  41. lifx_emulator/repositories/storage_backend.py +107 -0
  42. lifx_emulator/scenarios/__init__.py +22 -0
  43. lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
  44. lifx_emulator/scenarios/models.py +112 -0
  45. lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
  46. lifx_emulator/server.py +42 -66
  47. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/METADATA +1 -1
  48. lifx_emulator-2.1.0.dist-info/RECORD +62 -0
  49. lifx_emulator/device.py +0 -750
  50. lifx_emulator/device_states.py +0 -114
  51. lifx_emulator/factories.py +0 -380
  52. lifx_emulator/storage_protocol.py +0 -100
  53. lifx_emulator-1.0.2.dist-info/RECORD +0 -40
  54. /lifx_emulator/{observers.py → devices/observers.py} +0 -0
  55. /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
  56. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/WHEEL +0 -0
  57. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/entry_points.txt +0 -0
  58. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -505,16 +505,11 @@ class TileBufferRect:
505
505
 
506
506
  @dataclass
507
507
  class TileEffectParameter:
508
- """Auto-generated field structure."""
508
+ """Auto-generated field structure for Sky effects."""
509
509
 
510
- parameter0: int
511
- parameter1: int
512
- parameter2: int
513
- parameter3: int
514
- parameter4: int
515
- parameter5: int
516
- parameter6: int
517
- parameter7: int
510
+ sky_type: TileEffectSkyType
511
+ cloud_saturation_min: int
512
+ cloud_saturation_max: int
518
513
 
519
514
  def pack(self) -> bytes:
520
515
  """Pack to bytes."""
@@ -522,22 +517,18 @@ class TileEffectParameter:
522
517
 
523
518
  result = b""
524
519
 
525
- # parameter0: uint32
526
- result += serializer.pack_value(self.parameter0, "uint32")
527
- # parameter1: uint32
528
- result += serializer.pack_value(self.parameter1, "uint32")
529
- # parameter2: uint32
530
- result += serializer.pack_value(self.parameter2, "uint32")
531
- # parameter3: uint32
532
- result += serializer.pack_value(self.parameter3, "uint32")
533
- # parameter4: uint32
534
- result += serializer.pack_value(self.parameter4, "uint32")
535
- # parameter5: uint32
536
- result += serializer.pack_value(self.parameter5, "uint32")
537
- # parameter6: uint32
538
- result += serializer.pack_value(self.parameter6, "uint32")
539
- # parameter7: uint32
540
- result += serializer.pack_value(self.parameter7, "uint32")
520
+ # sky_type: TileEffectSkyType (enum)
521
+ result += serializer.pack_value(int(self.sky_type), "uint8")
522
+ # Reserved 3 bytes
523
+ result += serializer.pack_reserved(3)
524
+ # cloud_saturation_min: uint8
525
+ result += serializer.pack_value(self.cloud_saturation_min, "uint8")
526
+ # Reserved 3 bytes
527
+ result += serializer.pack_reserved(3)
528
+ # cloud_saturation_max: uint8
529
+ result += serializer.pack_value(self.cloud_saturation_max, "uint8")
530
+ # Reserved 23 bytes
531
+ result += serializer.pack_reserved(23)
541
532
 
542
533
  return result
543
534
 
@@ -547,49 +538,31 @@ class TileEffectParameter:
547
538
  from lifx_emulator.protocol import serializer
548
539
 
549
540
  current_offset = offset
550
- # parameter0: uint32
551
- parameter0, current_offset = serializer.unpack_value(
552
- data, "uint32", current_offset
553
- )
554
- # parameter1: uint32
555
- parameter1, current_offset = serializer.unpack_value(
556
- data, "uint32", current_offset
557
- )
558
- # parameter2: uint32
559
- parameter2, current_offset = serializer.unpack_value(
560
- data, "uint32", current_offset
561
- )
562
- # parameter3: uint32
563
- parameter3, current_offset = serializer.unpack_value(
564
- data, "uint32", current_offset
565
- )
566
- # parameter4: uint32
567
- parameter4, current_offset = serializer.unpack_value(
568
- data, "uint32", current_offset
569
- )
570
- # parameter5: uint32
571
- parameter5, current_offset = serializer.unpack_value(
572
- data, "uint32", current_offset
541
+ # sky_type: TileEffectSkyType (enum)
542
+ sky_type_raw, current_offset = serializer.unpack_value(
543
+ data, "uint8", current_offset
573
544
  )
574
- # parameter6: uint32
575
- parameter6, current_offset = serializer.unpack_value(
576
- data, "uint32", current_offset
545
+ sky_type = TileEffectSkyType(sky_type_raw)
546
+ # Skip reserved 3 bytes
547
+ current_offset += 3
548
+ # cloud_saturation_min: uint8
549
+ cloud_saturation_min, current_offset = serializer.unpack_value(
550
+ data, "uint8", current_offset
577
551
  )
578
- # parameter7: uint32
579
- parameter7, current_offset = serializer.unpack_value(
580
- data, "uint32", current_offset
552
+ # Skip reserved 3 bytes
553
+ current_offset += 3
554
+ # cloud_saturation_max: uint8
555
+ cloud_saturation_max, current_offset = serializer.unpack_value(
556
+ data, "uint8", current_offset
581
557
  )
558
+ # Skip reserved 23 bytes
559
+ current_offset += 23
582
560
 
583
561
  return (
584
562
  cls(
585
- parameter0=parameter0,
586
- parameter1=parameter1,
587
- parameter2=parameter2,
588
- parameter3=parameter3,
589
- parameter4=parameter4,
590
- parameter5=parameter5,
591
- parameter6=parameter6,
592
- parameter7=parameter7,
563
+ sky_type=sky_type,
564
+ cloud_saturation_min=cloud_saturation_min,
565
+ cloud_saturation_max=cloud_saturation_max,
593
566
  ),
594
567
  current_offset,
595
568
  )
@@ -0,0 +1,22 @@
1
+ """Repository interfaces and implementations for LIFX emulator.
2
+
3
+ This module defines repository abstractions following the Repository Pattern
4
+ and Dependency Inversion Principle. Repositories encapsulate data access logic
5
+ and provide a clean separation between domain logic and data persistence.
6
+ """
7
+
8
+ from lifx_emulator.repositories.device_repository import (
9
+ DeviceRepository,
10
+ IDeviceRepository,
11
+ )
12
+ from lifx_emulator.repositories.storage_backend import (
13
+ IDeviceStorageBackend,
14
+ IScenarioStorageBackend,
15
+ )
16
+
17
+ __all__ = [
18
+ "IDeviceRepository",
19
+ "DeviceRepository",
20
+ "IDeviceStorageBackend",
21
+ "IScenarioStorageBackend",
22
+ ]
@@ -0,0 +1,155 @@
1
+ """Device repository interface and implementation.
2
+
3
+ Provides abstraction for device storage and retrieval operations,
4
+ following the Repository Pattern and Dependency Inversion Principle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol, runtime_checkable
10
+
11
+ from lifx_emulator.devices import EmulatedLifxDevice
12
+
13
+
14
+ @runtime_checkable
15
+ class IDeviceRepository(Protocol):
16
+ """Interface for device repository operations.
17
+
18
+ This protocol defines the contract for managing device storage and retrieval.
19
+ Concrete implementations can use in-memory storage, databases, or other backends.
20
+ """
21
+
22
+ def add(self, device: EmulatedLifxDevice) -> bool:
23
+ """Add a device to the repository.
24
+
25
+ Args:
26
+ device: Device to add
27
+
28
+ Returns:
29
+ True if device was added, False if device with same serial already exists
30
+ """
31
+ ...
32
+
33
+ def remove(self, serial: str) -> bool:
34
+ """Remove a device from the repository.
35
+
36
+ Args:
37
+ serial: Serial number of device to remove
38
+
39
+ Returns:
40
+ True if device was removed, False if not found
41
+ """
42
+ ...
43
+
44
+ def get(self, serial: str) -> EmulatedLifxDevice | None:
45
+ """Get a device by serial number.
46
+
47
+ Args:
48
+ serial: Serial number to look up
49
+
50
+ Returns:
51
+ Device if found, None otherwise
52
+ """
53
+ ...
54
+
55
+ def get_all(self) -> list[EmulatedLifxDevice]:
56
+ """Get all devices.
57
+
58
+ Returns:
59
+ List of all devices in the repository
60
+ """
61
+ ...
62
+
63
+ def clear(self) -> int:
64
+ """Remove all devices from the repository.
65
+
66
+ Returns:
67
+ Number of devices removed
68
+ """
69
+ ...
70
+
71
+ def count(self) -> int:
72
+ """Get the number of devices in the repository.
73
+
74
+ Returns:
75
+ Number of devices
76
+ """
77
+ ...
78
+
79
+
80
+ class DeviceRepository:
81
+ """In-memory device repository implementation.
82
+
83
+ Stores devices in a dictionary keyed by serial number.
84
+ This is the default implementation used by EmulatedLifxServer.
85
+ """
86
+
87
+ def __init__(self) -> None:
88
+ """Initialize empty device repository."""
89
+ self._devices: dict[str, EmulatedLifxDevice] = {}
90
+
91
+ def add(self, device: EmulatedLifxDevice) -> bool:
92
+ """Add a device to the repository.
93
+
94
+ Args:
95
+ device: Device to add
96
+
97
+ Returns:
98
+ True if device was added, False if device with same serial already exists
99
+ """
100
+ serial = device.state.serial
101
+ if serial in self._devices:
102
+ return False
103
+ self._devices[serial] = device
104
+ return True
105
+
106
+ def remove(self, serial: str) -> bool:
107
+ """Remove a device from the repository.
108
+
109
+ Args:
110
+ serial: Serial number of device to remove
111
+
112
+ Returns:
113
+ True if device was removed, False if not found
114
+ """
115
+ if serial in self._devices:
116
+ del self._devices[serial]
117
+ return True
118
+ return False
119
+
120
+ def get(self, serial: str) -> EmulatedLifxDevice | None:
121
+ """Get a device by serial number.
122
+
123
+ Args:
124
+ serial: Serial number to look up
125
+
126
+ Returns:
127
+ Device if found, None otherwise
128
+ """
129
+ return self._devices.get(serial)
130
+
131
+ def get_all(self) -> list[EmulatedLifxDevice]:
132
+ """Get all devices.
133
+
134
+ Returns:
135
+ List of all devices in the repository
136
+ """
137
+ return list(self._devices.values())
138
+
139
+ def clear(self) -> int:
140
+ """Remove all devices from the repository.
141
+
142
+ Returns:
143
+ Number of devices removed
144
+ """
145
+ count = len(self._devices)
146
+ self._devices.clear()
147
+ return count
148
+
149
+ def count(self) -> int:
150
+ """Get the number of devices in the repository.
151
+
152
+ Returns:
153
+ Number of devices
154
+ """
155
+ return len(self._devices)
@@ -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 dataclasses import dataclass, field
9
- from typing import TYPE_CHECKING, Any
8
+ from typing import TYPE_CHECKING
10
9
 
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
- """
10
+ from lifx_emulator.scenarios.models import ScenarioConfig
35
11
 
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
- )
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
+ }