lifx-emulator 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lifx_emulator/__init__.py +31 -0
- lifx_emulator/__main__.py +607 -0
- lifx_emulator/api.py +1825 -0
- lifx_emulator/async_storage.py +308 -0
- lifx_emulator/constants.py +33 -0
- lifx_emulator/device.py +750 -0
- lifx_emulator/device_states.py +114 -0
- lifx_emulator/factories.py +380 -0
- lifx_emulator/handlers/__init__.py +39 -0
- lifx_emulator/handlers/base.py +49 -0
- lifx_emulator/handlers/device_handlers.py +340 -0
- lifx_emulator/handlers/light_handlers.py +372 -0
- lifx_emulator/handlers/multizone_handlers.py +249 -0
- lifx_emulator/handlers/registry.py +110 -0
- lifx_emulator/handlers/tile_handlers.py +309 -0
- lifx_emulator/observers.py +139 -0
- lifx_emulator/products/__init__.py +28 -0
- lifx_emulator/products/generator.py +771 -0
- lifx_emulator/products/registry.py +1446 -0
- lifx_emulator/products/specs.py +242 -0
- lifx_emulator/products/specs.yml +327 -0
- lifx_emulator/protocol/__init__.py +1 -0
- lifx_emulator/protocol/base.py +334 -0
- lifx_emulator/protocol/const.py +8 -0
- lifx_emulator/protocol/generator.py +1371 -0
- lifx_emulator/protocol/header.py +159 -0
- lifx_emulator/protocol/packets.py +1351 -0
- lifx_emulator/protocol/protocol_types.py +844 -0
- lifx_emulator/protocol/serializer.py +379 -0
- lifx_emulator/scenario_manager.py +402 -0
- lifx_emulator/scenario_persistence.py +206 -0
- lifx_emulator/server.py +482 -0
- lifx_emulator/state_restorer.py +259 -0
- lifx_emulator/state_serializer.py +130 -0
- lifx_emulator/storage_protocol.py +100 -0
- lifx_emulator-1.0.0.dist-info/METADATA +445 -0
- lifx_emulator-1.0.0.dist-info/RECORD +40 -0
- lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
- lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
- lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Focused state dataclasses following Single Responsibility Principle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from lifx_emulator.constants import LIFX_UDP_PORT
|
|
11
|
+
from lifx_emulator.protocol.protocol_types import LightHsbk
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CoreDeviceState:
|
|
16
|
+
"""Core device identification and basic state."""
|
|
17
|
+
|
|
18
|
+
serial: str
|
|
19
|
+
label: str
|
|
20
|
+
power_level: int
|
|
21
|
+
color: LightHsbk
|
|
22
|
+
vendor: int
|
|
23
|
+
product: int
|
|
24
|
+
version_major: int
|
|
25
|
+
version_minor: int
|
|
26
|
+
build_timestamp: int
|
|
27
|
+
uptime_ns: int = 0
|
|
28
|
+
mac_address: bytes = field(default_factory=lambda: bytes.fromhex("d073d5123456"))
|
|
29
|
+
port: int = LIFX_UDP_PORT
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class NetworkState:
|
|
34
|
+
"""Network and connectivity state."""
|
|
35
|
+
|
|
36
|
+
wifi_signal: float = -45.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class LocationState:
|
|
41
|
+
"""Device location metadata."""
|
|
42
|
+
|
|
43
|
+
location_id: bytes = field(default_factory=lambda: uuid.uuid4().bytes)
|
|
44
|
+
location_label: str = "Test Location"
|
|
45
|
+
location_updated_at: int = field(default_factory=lambda: int(time.time() * 1e9))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class GroupState:
|
|
50
|
+
"""Device group metadata."""
|
|
51
|
+
|
|
52
|
+
group_id: bytes = field(default_factory=lambda: uuid.uuid4().bytes)
|
|
53
|
+
group_label: str = "Test Group"
|
|
54
|
+
group_updated_at: int = field(default_factory=lambda: int(time.time() * 1e9))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class InfraredState:
|
|
59
|
+
"""Infrared capability state."""
|
|
60
|
+
|
|
61
|
+
infrared_brightness: int = 0 # 0-65535
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class HevState:
|
|
66
|
+
"""HEV (germicidal UV) capability state."""
|
|
67
|
+
|
|
68
|
+
hev_cycle_duration_s: int = 7200 # 2 hours default
|
|
69
|
+
hev_cycle_remaining_s: int = 0
|
|
70
|
+
hev_cycle_last_power: bool = False
|
|
71
|
+
hev_indication: bool = True
|
|
72
|
+
hev_last_result: int = 0 # 0=success
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MultiZoneState:
|
|
77
|
+
"""Multizone (strip/beam) capability state."""
|
|
78
|
+
|
|
79
|
+
zone_count: int
|
|
80
|
+
zone_colors: list[LightHsbk]
|
|
81
|
+
effect_type: int = 0 # 0=OFF, 1=MOVE, 2=RESERVED
|
|
82
|
+
effect_speed: int = 5 # Duration of one cycle in seconds
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class MatrixState:
|
|
87
|
+
"""Matrix (tile/candle) capability state."""
|
|
88
|
+
|
|
89
|
+
tile_count: int
|
|
90
|
+
tile_devices: list[dict[str, Any]]
|
|
91
|
+
tile_width: int
|
|
92
|
+
tile_height: int
|
|
93
|
+
effect_type: int = 0 # 0=OFF, 2=MORPH, 3=FLAME
|
|
94
|
+
effect_speed: int = 5 # Duration of one cycle in seconds
|
|
95
|
+
effect_palette_count: int = 0
|
|
96
|
+
effect_palette: list[LightHsbk] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class WaveformState:
|
|
101
|
+
"""Waveform effect state."""
|
|
102
|
+
|
|
103
|
+
waveform_active: bool = False
|
|
104
|
+
waveform_type: int = 0
|
|
105
|
+
waveform_transient: bool = False
|
|
106
|
+
waveform_color: LightHsbk = field(
|
|
107
|
+
default_factory=lambda: LightHsbk(
|
|
108
|
+
hue=0, saturation=0, brightness=0, kelvin=3500
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
waveform_period_ms: int = 0
|
|
112
|
+
waveform_cycles: float = 0
|
|
113
|
+
waveform_duty_cycle: int = 0
|
|
114
|
+
waveform_skew_ratio: int = 0
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Factory functions for creating emulated LIFX devices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from lifx_emulator.device import DeviceState, EmulatedLifxDevice
|
|
10
|
+
from lifx_emulator.device_states import (
|
|
11
|
+
CoreDeviceState,
|
|
12
|
+
GroupState,
|
|
13
|
+
HevState,
|
|
14
|
+
InfraredState,
|
|
15
|
+
LocationState,
|
|
16
|
+
MatrixState,
|
|
17
|
+
MultiZoneState,
|
|
18
|
+
NetworkState,
|
|
19
|
+
WaveformState,
|
|
20
|
+
)
|
|
21
|
+
from lifx_emulator.products.registry import ProductInfo, get_product
|
|
22
|
+
from lifx_emulator.products.specs import (
|
|
23
|
+
get_default_tile_count,
|
|
24
|
+
get_default_zone_count,
|
|
25
|
+
get_tile_dimensions,
|
|
26
|
+
)
|
|
27
|
+
from lifx_emulator.protocol.protocol_types import LightHsbk
|
|
28
|
+
from lifx_emulator.state_restorer import StateRestorer
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from lifx_emulator.async_storage import AsyncDeviceStorage
|
|
32
|
+
from lifx_emulator.scenario_manager import HierarchicalScenarioManager
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_color_light(
|
|
36
|
+
serial: str | None = None,
|
|
37
|
+
firmware_version: tuple[int, int] | None = None,
|
|
38
|
+
storage: AsyncDeviceStorage | None = None,
|
|
39
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
40
|
+
) -> EmulatedLifxDevice:
|
|
41
|
+
"""Create a regular color light (LIFX Color)"""
|
|
42
|
+
return create_device(
|
|
43
|
+
91,
|
|
44
|
+
serial=serial,
|
|
45
|
+
firmware_version=firmware_version,
|
|
46
|
+
storage=storage,
|
|
47
|
+
scenario_manager=scenario_manager,
|
|
48
|
+
) # LIFX Color
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_infrared_light(
|
|
52
|
+
serial: str | None = None,
|
|
53
|
+
firmware_version: tuple[int, int] | None = None,
|
|
54
|
+
storage: AsyncDeviceStorage | None = None,
|
|
55
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
56
|
+
) -> EmulatedLifxDevice:
|
|
57
|
+
"""Create an infrared-enabled light (LIFX A19 Night Vision)"""
|
|
58
|
+
return create_device(
|
|
59
|
+
29,
|
|
60
|
+
serial=serial,
|
|
61
|
+
firmware_version=firmware_version,
|
|
62
|
+
storage=storage,
|
|
63
|
+
scenario_manager=scenario_manager,
|
|
64
|
+
) # LIFX A19 Night Vision
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_hev_light(
|
|
68
|
+
serial: str | None = None,
|
|
69
|
+
firmware_version: tuple[int, int] | None = None,
|
|
70
|
+
storage: AsyncDeviceStorage | None = None,
|
|
71
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
72
|
+
) -> EmulatedLifxDevice:
|
|
73
|
+
"""Create an HEV-enabled light (LIFX Clean)"""
|
|
74
|
+
return create_device(
|
|
75
|
+
90,
|
|
76
|
+
serial=serial,
|
|
77
|
+
firmware_version=firmware_version,
|
|
78
|
+
storage=storage,
|
|
79
|
+
scenario_manager=scenario_manager,
|
|
80
|
+
) # LIFX Clean
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def create_multizone_light(
|
|
84
|
+
serial: str | None = None,
|
|
85
|
+
zone_count: int | None = None,
|
|
86
|
+
extended_multizone: bool = True,
|
|
87
|
+
firmware_version: tuple[int, int] | None = None,
|
|
88
|
+
storage: AsyncDeviceStorage | None = None,
|
|
89
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
90
|
+
) -> EmulatedLifxDevice:
|
|
91
|
+
"""Create a multizone light (LIFX Beam)
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
serial: Optional serial
|
|
95
|
+
zone_count: Optional zone count (uses product default if not specified)
|
|
96
|
+
extended_multizone: enables support for extended multizone requests
|
|
97
|
+
firmware_version: Optional firmware version tuple (major, minor)
|
|
98
|
+
storage: Optional storage for persistence
|
|
99
|
+
scenario_manager: Optional scenario manager
|
|
100
|
+
"""
|
|
101
|
+
return create_device(
|
|
102
|
+
38,
|
|
103
|
+
serial=serial,
|
|
104
|
+
zone_count=zone_count,
|
|
105
|
+
extended_multizone=extended_multizone,
|
|
106
|
+
firmware_version=firmware_version,
|
|
107
|
+
storage=storage,
|
|
108
|
+
scenario_manager=scenario_manager,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_tile_device(
|
|
113
|
+
serial: str | None = None,
|
|
114
|
+
tile_count: int | None = None,
|
|
115
|
+
tile_width: int | None = None,
|
|
116
|
+
tile_height: int | None = None,
|
|
117
|
+
firmware_version: tuple[int, int] | None = None,
|
|
118
|
+
storage: AsyncDeviceStorage | None = None,
|
|
119
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
120
|
+
) -> EmulatedLifxDevice:
|
|
121
|
+
"""Create a tile device (LIFX Tile)
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
serial: Optional serial
|
|
125
|
+
tile_count: Optional tile count (uses product default)
|
|
126
|
+
tile_width: Optional tile width in pixels (uses product default)
|
|
127
|
+
tile_height: Optional tile height in pixels (uses product default)
|
|
128
|
+
firmware_version: Optional firmware version tuple (major, minor)
|
|
129
|
+
storage: Optional storage for persistence
|
|
130
|
+
scenario_manager: Optional scenario manager
|
|
131
|
+
"""
|
|
132
|
+
return create_device(
|
|
133
|
+
55,
|
|
134
|
+
serial=serial,
|
|
135
|
+
tile_count=tile_count,
|
|
136
|
+
tile_width=tile_width,
|
|
137
|
+
tile_height=tile_height,
|
|
138
|
+
firmware_version=firmware_version,
|
|
139
|
+
storage=storage,
|
|
140
|
+
scenario_manager=scenario_manager,
|
|
141
|
+
) # LIFX Tile
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def create_color_temperature_light(
|
|
145
|
+
serial: str | None = None,
|
|
146
|
+
firmware_version: tuple[int, int] | None = None,
|
|
147
|
+
storage: AsyncDeviceStorage | None = None,
|
|
148
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
149
|
+
) -> EmulatedLifxDevice:
|
|
150
|
+
"""Create a color temperature light (LIFX Mini White to Warm).
|
|
151
|
+
|
|
152
|
+
Variable color temperature, no RGB.
|
|
153
|
+
"""
|
|
154
|
+
return create_device(
|
|
155
|
+
50,
|
|
156
|
+
serial=serial,
|
|
157
|
+
firmware_version=firmware_version,
|
|
158
|
+
storage=storage,
|
|
159
|
+
scenario_manager=scenario_manager,
|
|
160
|
+
) # LIFX Mini White to Warm
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def create_device(
|
|
164
|
+
product_id: int,
|
|
165
|
+
serial: str | None = None,
|
|
166
|
+
zone_count: int | None = None,
|
|
167
|
+
extended_multizone: bool | None = None,
|
|
168
|
+
tile_count: int | None = None,
|
|
169
|
+
tile_width: int | None = None,
|
|
170
|
+
tile_height: int | None = None,
|
|
171
|
+
firmware_version: tuple[int, int] | None = None,
|
|
172
|
+
storage: AsyncDeviceStorage | None = None,
|
|
173
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
174
|
+
) -> EmulatedLifxDevice:
|
|
175
|
+
"""Create a device for any LIFX product using the product registry.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
product_id: Product ID from the LIFX product registry
|
|
179
|
+
serial: Optional serial (auto-generated if not provided)
|
|
180
|
+
zone_count: Number of zones for multizone devices (auto-determined)
|
|
181
|
+
extended_multizone: Enable extended multizone requests
|
|
182
|
+
tile_count: Number of tiles for matrix devices (default: 5)
|
|
183
|
+
tile_width: Width of each tile in pixels (default: 8)
|
|
184
|
+
tile_height: Height of each tile in pixels (default: 8)
|
|
185
|
+
firmware_version: Optional firmware version tuple (major, minor).
|
|
186
|
+
If not specified, uses 3.70 for extended_multizone
|
|
187
|
+
or 2.60 otherwise
|
|
188
|
+
storage: Optional storage for persistence
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
EmulatedLifxDevice configured for the specified product
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
ValueError: If product_id is not found in registry
|
|
195
|
+
|
|
196
|
+
Examples:
|
|
197
|
+
>>> # Create LIFX A19 (PID 27)
|
|
198
|
+
>>> device = create_device(27)
|
|
199
|
+
>>> # Create LIFX Z strip (PID 32) with 24 zones
|
|
200
|
+
>>> strip = create_device(32, zone_count=24)
|
|
201
|
+
>>> # Create LIFX Tile (PID 55) with 10 tiles
|
|
202
|
+
>>> tiles = create_device(55, tile_count=10)
|
|
203
|
+
"""
|
|
204
|
+
# Get product info from registry
|
|
205
|
+
product_info: ProductInfo | None = get_product(product_id)
|
|
206
|
+
if product_info is None:
|
|
207
|
+
raise ValueError(f"Unknown product ID: {product_id}")
|
|
208
|
+
|
|
209
|
+
# Generate serial if not provided
|
|
210
|
+
if not serial:
|
|
211
|
+
# Use different prefixes for product types for easier identification
|
|
212
|
+
if product_info.has_matrix:
|
|
213
|
+
prefix = "d073d9" # Tiles
|
|
214
|
+
elif product_info.has_multizone:
|
|
215
|
+
prefix = "d073d8" # Strips/Beams
|
|
216
|
+
elif product_info.has_hev:
|
|
217
|
+
prefix = "d073d7" # HEV
|
|
218
|
+
elif product_info.has_infrared:
|
|
219
|
+
prefix = "d073d6" # Infrared
|
|
220
|
+
else:
|
|
221
|
+
prefix = "d073d5" # Regular lights
|
|
222
|
+
serial = f"{prefix}{random.randint(100000, 999999):06x}" # nosec
|
|
223
|
+
|
|
224
|
+
# Determine zone count for multizone devices
|
|
225
|
+
if product_info.has_multizone and zone_count is None:
|
|
226
|
+
# Try to get default from specs first
|
|
227
|
+
zone_count = get_default_zone_count(product_id) or 16
|
|
228
|
+
|
|
229
|
+
# Determine tile configuration for matrix devices
|
|
230
|
+
if product_info.has_matrix:
|
|
231
|
+
# Get tile dimensions from specs (always use specs for dimensions)
|
|
232
|
+
tile_dims = get_tile_dimensions(product_id)
|
|
233
|
+
if tile_dims:
|
|
234
|
+
tile_width, tile_height = tile_dims
|
|
235
|
+
else:
|
|
236
|
+
# Fallback to standard 8x8 tiles
|
|
237
|
+
if tile_width is None:
|
|
238
|
+
tile_width = 8
|
|
239
|
+
if tile_height is None:
|
|
240
|
+
tile_height = 8
|
|
241
|
+
|
|
242
|
+
# Get default tile count from specs
|
|
243
|
+
if tile_count is None:
|
|
244
|
+
specs_tile_count = get_default_tile_count(product_id)
|
|
245
|
+
if specs_tile_count is not None:
|
|
246
|
+
tile_count = specs_tile_count
|
|
247
|
+
else:
|
|
248
|
+
tile_count = 5 # Generic default
|
|
249
|
+
|
|
250
|
+
# Create default color based on product type
|
|
251
|
+
if (
|
|
252
|
+
not product_info.has_color
|
|
253
|
+
and product_info.temperature_range is not None
|
|
254
|
+
and product_info.temperature_range.min == product_info.temperature_range.max
|
|
255
|
+
):
|
|
256
|
+
# Brightness only light
|
|
257
|
+
default_color = LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=2700)
|
|
258
|
+
elif (
|
|
259
|
+
not product_info.has_color
|
|
260
|
+
and product_info.temperature_range is not None
|
|
261
|
+
and product_info.temperature_range.min != product_info.temperature_range.max
|
|
262
|
+
):
|
|
263
|
+
# Color temperature adjustable light
|
|
264
|
+
default_color = LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=3500)
|
|
265
|
+
else:
|
|
266
|
+
# Color devices - use a unique hue per device type
|
|
267
|
+
hue_map = {
|
|
268
|
+
"matrix": 43690, # Cyan
|
|
269
|
+
"multizone": 0, # Red
|
|
270
|
+
"hev": 32768, # Green
|
|
271
|
+
"infrared": 0, # Red
|
|
272
|
+
"color": 21845, # Orange
|
|
273
|
+
}
|
|
274
|
+
if product_info.has_matrix:
|
|
275
|
+
hue = hue_map["matrix"]
|
|
276
|
+
elif product_info.has_multizone:
|
|
277
|
+
hue = hue_map["multizone"]
|
|
278
|
+
elif product_info.has_hev:
|
|
279
|
+
hue = hue_map["hev"]
|
|
280
|
+
elif product_info.has_infrared:
|
|
281
|
+
hue = hue_map["infrared"]
|
|
282
|
+
else:
|
|
283
|
+
hue = hue_map["color"]
|
|
284
|
+
default_color = LightHsbk(
|
|
285
|
+
hue=hue, saturation=65535, brightness=32768, kelvin=3500
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Get a simplified label from product name
|
|
289
|
+
label = f"{product_info.name} {serial[-6:]}"
|
|
290
|
+
|
|
291
|
+
# Determine firmware version: use extended_multizone to set default,
|
|
292
|
+
# then override with explicit firmware_version if provided
|
|
293
|
+
# None defaults to True (3.70), only explicit False gives 2.60
|
|
294
|
+
if extended_multizone is False:
|
|
295
|
+
version_major = 2
|
|
296
|
+
version_minor = 60
|
|
297
|
+
else:
|
|
298
|
+
version_major = 3
|
|
299
|
+
version_minor = 70
|
|
300
|
+
|
|
301
|
+
# Override with explicit firmware_version if provided
|
|
302
|
+
if firmware_version is not None:
|
|
303
|
+
version_major, version_minor = firmware_version
|
|
304
|
+
|
|
305
|
+
core = CoreDeviceState(
|
|
306
|
+
serial=serial,
|
|
307
|
+
label=label,
|
|
308
|
+
power_level=65535, # Default to on
|
|
309
|
+
color=default_color,
|
|
310
|
+
vendor=product_info.vendor,
|
|
311
|
+
product=product_info.pid,
|
|
312
|
+
version_major=version_major,
|
|
313
|
+
version_minor=version_minor,
|
|
314
|
+
build_timestamp=int(time.time()),
|
|
315
|
+
mac_address=bytes.fromhex(serial[:12]),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Create network, location, group, and waveform state
|
|
319
|
+
network = NetworkState()
|
|
320
|
+
location = LocationState()
|
|
321
|
+
group = GroupState()
|
|
322
|
+
waveform = WaveformState()
|
|
323
|
+
|
|
324
|
+
# Create capability-specific state objects
|
|
325
|
+
infrared_state = (
|
|
326
|
+
InfraredState(infrared_brightness=16384) if product_info.has_infrared else None
|
|
327
|
+
)
|
|
328
|
+
hev_state = HevState() if product_info.has_hev else None
|
|
329
|
+
|
|
330
|
+
multizone_state = None
|
|
331
|
+
if product_info.has_multizone and zone_count:
|
|
332
|
+
multizone_state = MultiZoneState(
|
|
333
|
+
zone_count=zone_count,
|
|
334
|
+
zone_colors=[], # Will be initialized by EmulatedLifxDevice
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
matrix_state = None
|
|
338
|
+
if product_info.has_matrix and tile_count:
|
|
339
|
+
matrix_state = MatrixState(
|
|
340
|
+
tile_count=tile_count,
|
|
341
|
+
tile_devices=[], # Will be initialized by EmulatedLifxDevice
|
|
342
|
+
tile_width=tile_width or 8,
|
|
343
|
+
tile_height=tile_height or 8,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Determine if device supports extended multizone
|
|
347
|
+
firmware_version_int = (version_major << 16) | version_minor
|
|
348
|
+
has_extended_multizone = product_info.supports_extended_multizone(
|
|
349
|
+
firmware_version_int
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Create composed device state
|
|
353
|
+
state = DeviceState(
|
|
354
|
+
core=core,
|
|
355
|
+
network=network,
|
|
356
|
+
location=location,
|
|
357
|
+
group=group,
|
|
358
|
+
waveform=waveform,
|
|
359
|
+
infrared=infrared_state,
|
|
360
|
+
hev=hev_state,
|
|
361
|
+
multizone=multizone_state,
|
|
362
|
+
matrix=matrix_state,
|
|
363
|
+
has_color=product_info.has_color,
|
|
364
|
+
has_infrared=product_info.has_infrared,
|
|
365
|
+
has_multizone=product_info.has_multizone,
|
|
366
|
+
has_extended_multizone=has_extended_multizone,
|
|
367
|
+
has_matrix=product_info.has_matrix,
|
|
368
|
+
has_hev=product_info.has_hev,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Restore saved state if persistence is enabled
|
|
372
|
+
if storage:
|
|
373
|
+
restorer = StateRestorer(storage)
|
|
374
|
+
restorer.restore_if_available(state)
|
|
375
|
+
|
|
376
|
+
return EmulatedLifxDevice(
|
|
377
|
+
state,
|
|
378
|
+
storage=storage,
|
|
379
|
+
scenario_manager=scenario_manager,
|
|
380
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Packet handler infrastructure using Strategy pattern.
|
|
2
|
+
|
|
3
|
+
This module provides the base classes and registry for handling LIFX protocol packets.
|
|
4
|
+
Each packet type has a dedicated handler class that implements the business logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from lifx_emulator.handlers.base import PacketHandler
|
|
8
|
+
from lifx_emulator.handlers.device_handlers import ALL_DEVICE_HANDLERS
|
|
9
|
+
from lifx_emulator.handlers.light_handlers import ALL_LIGHT_HANDLERS
|
|
10
|
+
from lifx_emulator.handlers.multizone_handlers import ALL_MULTIZONE_HANDLERS
|
|
11
|
+
from lifx_emulator.handlers.registry import HandlerRegistry
|
|
12
|
+
from lifx_emulator.handlers.tile_handlers import ALL_TILE_HANDLERS
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"PacketHandler",
|
|
16
|
+
"HandlerRegistry",
|
|
17
|
+
"ALL_DEVICE_HANDLERS",
|
|
18
|
+
"ALL_LIGHT_HANDLERS",
|
|
19
|
+
"ALL_MULTIZONE_HANDLERS",
|
|
20
|
+
"ALL_TILE_HANDLERS",
|
|
21
|
+
"create_default_registry",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_default_registry() -> HandlerRegistry:
|
|
26
|
+
"""Create a handler registry with all default handlers registered.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
HandlerRegistry with all built-in handlers
|
|
30
|
+
"""
|
|
31
|
+
registry = HandlerRegistry()
|
|
32
|
+
|
|
33
|
+
# Register all handler categories
|
|
34
|
+
registry.register_all(ALL_DEVICE_HANDLERS)
|
|
35
|
+
registry.register_all(ALL_LIGHT_HANDLERS)
|
|
36
|
+
registry.register_all(ALL_MULTIZONE_HANDLERS)
|
|
37
|
+
registry.register_all(ALL_TILE_HANDLERS)
|
|
38
|
+
|
|
39
|
+
return registry
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Base classes for packet handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from lifx_emulator.device import DeviceState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PacketHandler(ABC):
|
|
13
|
+
"""Base class for all packet handlers.
|
|
14
|
+
|
|
15
|
+
Each handler implements the logic for processing a specific packet type
|
|
16
|
+
and optionally generating a response packet.
|
|
17
|
+
|
|
18
|
+
Handlers are stateless and operate on the provided DeviceState.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Subclasses must define the packet type they handle
|
|
22
|
+
PKT_TYPE: int
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def handle(
|
|
26
|
+
self, device_state: DeviceState, packet: Any | None, res_required: bool
|
|
27
|
+
) -> list[Any]:
|
|
28
|
+
"""Handle the packet and return response packet(s).
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
device_state: Current device state to read/modify
|
|
32
|
+
packet: Unpacked packet object (None for packets with no payload)
|
|
33
|
+
res_required: Whether client requested a response (res_required
|
|
34
|
+
flag from header)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of response packets (empty list if no response needed).
|
|
38
|
+
This unified return type simplifies packet processing logic.
|
|
39
|
+
|
|
40
|
+
Notes:
|
|
41
|
+
- Handlers should modify device_state directly for SET operations
|
|
42
|
+
- Handlers should check device capabilities before processing
|
|
43
|
+
- Return empty list [] if the device doesn't support this packet type
|
|
44
|
+
- Always return a list, even for single responses: [packet]
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return f"{self.__class__.__name__}(PKT_TYPE={self.PKT_TYPE})"
|