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.
- 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 +346 -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 +31 -11
- 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 +175 -63
- lifx_emulator/protocol/generator.py +18 -5
- lifx_emulator/protocol/packets.py +7 -7
- lifx_emulator/protocol/protocol_types.py +35 -62
- 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 +42 -66
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/METADATA +1 -1
- lifx_emulator-2.1.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.1.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
#
|
|
526
|
-
result += serializer.pack_value(self.
|
|
527
|
-
#
|
|
528
|
-
result += serializer.
|
|
529
|
-
#
|
|
530
|
-
result += serializer.pack_value(self.
|
|
531
|
-
#
|
|
532
|
-
result += serializer.
|
|
533
|
-
#
|
|
534
|
-
result += serializer.pack_value(self.
|
|
535
|
-
#
|
|
536
|
-
result += serializer.
|
|
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
|
-
#
|
|
551
|
-
|
|
552
|
-
data, "
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
#
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
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
|
+
}
|