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
lifx_emulator/device.py
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Device state and emulated device implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import copy
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from lifx_emulator.constants import LIFX_HEADER_SIZE
|
|
13
|
+
from lifx_emulator.device_states import (
|
|
14
|
+
CoreDeviceState,
|
|
15
|
+
GroupState,
|
|
16
|
+
HevState,
|
|
17
|
+
InfraredState,
|
|
18
|
+
LocationState,
|
|
19
|
+
MatrixState,
|
|
20
|
+
MultiZoneState,
|
|
21
|
+
NetworkState,
|
|
22
|
+
WaveformState,
|
|
23
|
+
)
|
|
24
|
+
from lifx_emulator.handlers import HandlerRegistry, create_default_registry
|
|
25
|
+
from lifx_emulator.protocol.header import LifxHeader
|
|
26
|
+
from lifx_emulator.protocol.packets import (
|
|
27
|
+
Device,
|
|
28
|
+
)
|
|
29
|
+
from lifx_emulator.protocol.protocol_types import LightHsbk
|
|
30
|
+
from lifx_emulator.scenario_manager import (
|
|
31
|
+
HierarchicalScenarioManager,
|
|
32
|
+
ScenarioConfig,
|
|
33
|
+
get_device_type,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Forward declaration for type hinting
|
|
39
|
+
TYPE_CHECKING = False
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from lifx_emulator.async_storage import AsyncDeviceStorage
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class DeviceState:
|
|
46
|
+
"""Composed device state following Single Responsibility Principle.
|
|
47
|
+
|
|
48
|
+
Each aspect of device state is managed by a focused sub-state object.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
core: CoreDeviceState
|
|
52
|
+
network: NetworkState
|
|
53
|
+
location: LocationState
|
|
54
|
+
group: GroupState
|
|
55
|
+
waveform: WaveformState
|
|
56
|
+
|
|
57
|
+
# Optional capability-specific state
|
|
58
|
+
infrared: InfraredState | None = None
|
|
59
|
+
hev: HevState | None = None
|
|
60
|
+
multizone: MultiZoneState | None = None
|
|
61
|
+
matrix: MatrixState | None = None
|
|
62
|
+
|
|
63
|
+
# Capability flags (kept for convenience)
|
|
64
|
+
has_color: bool = True
|
|
65
|
+
has_infrared: bool = False
|
|
66
|
+
has_multizone: bool = False
|
|
67
|
+
has_extended_multizone: bool = False
|
|
68
|
+
has_matrix: bool = False
|
|
69
|
+
has_hev: bool = False
|
|
70
|
+
|
|
71
|
+
def get_target_bytes(self) -> bytes:
|
|
72
|
+
"""Get target bytes for this device"""
|
|
73
|
+
return bytes.fromhex(self.core.serial) + b"\x00\x00"
|
|
74
|
+
|
|
75
|
+
# Convenience properties for commonly accessed core fields
|
|
76
|
+
@property
|
|
77
|
+
def serial(self) -> str:
|
|
78
|
+
return self.core.serial
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def label(self) -> str:
|
|
82
|
+
return self.core.label
|
|
83
|
+
|
|
84
|
+
@label.setter
|
|
85
|
+
def label(self, value: str):
|
|
86
|
+
self.core.label = value
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def power_level(self) -> int:
|
|
90
|
+
return self.core.power_level
|
|
91
|
+
|
|
92
|
+
@power_level.setter
|
|
93
|
+
def power_level(self, value: int):
|
|
94
|
+
self.core.power_level = value
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def color(self) -> LightHsbk:
|
|
98
|
+
return self.core.color
|
|
99
|
+
|
|
100
|
+
@color.setter
|
|
101
|
+
def color(self, value: LightHsbk):
|
|
102
|
+
self.core.color = value
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def vendor(self) -> int:
|
|
106
|
+
return self.core.vendor
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def product(self) -> int:
|
|
110
|
+
return self.core.product
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def version_major(self) -> int:
|
|
114
|
+
return self.core.version_major
|
|
115
|
+
|
|
116
|
+
@version_major.setter
|
|
117
|
+
def version_major(self, value: int):
|
|
118
|
+
self.core.version_major = value
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def version_minor(self) -> int:
|
|
122
|
+
return self.core.version_minor
|
|
123
|
+
|
|
124
|
+
@version_minor.setter
|
|
125
|
+
def version_minor(self, value: int):
|
|
126
|
+
self.core.version_minor = value
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def build_timestamp(self) -> int:
|
|
130
|
+
return self.core.build_timestamp
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def uptime_ns(self) -> int:
|
|
134
|
+
return self.core.uptime_ns
|
|
135
|
+
|
|
136
|
+
@uptime_ns.setter
|
|
137
|
+
def uptime_ns(self, value: int):
|
|
138
|
+
self.core.uptime_ns = value
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def mac_address(self) -> bytes:
|
|
142
|
+
return self.core.mac_address
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def port(self) -> int:
|
|
146
|
+
return self.core.port
|
|
147
|
+
|
|
148
|
+
@port.setter
|
|
149
|
+
def port(self, value: int):
|
|
150
|
+
self.core.port = value
|
|
151
|
+
|
|
152
|
+
# Network properties
|
|
153
|
+
@property
|
|
154
|
+
def wifi_signal(self) -> float:
|
|
155
|
+
return self.network.wifi_signal
|
|
156
|
+
|
|
157
|
+
# Location properties
|
|
158
|
+
@property
|
|
159
|
+
def location_id(self) -> bytes:
|
|
160
|
+
return self.location.location_id
|
|
161
|
+
|
|
162
|
+
@location_id.setter
|
|
163
|
+
def location_id(self, value: bytes):
|
|
164
|
+
self.location.location_id = value
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def location_label(self) -> str:
|
|
168
|
+
return self.location.location_label
|
|
169
|
+
|
|
170
|
+
@location_label.setter
|
|
171
|
+
def location_label(self, value: str):
|
|
172
|
+
self.location.location_label = value
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def location_updated_at(self) -> int:
|
|
176
|
+
return self.location.location_updated_at
|
|
177
|
+
|
|
178
|
+
@location_updated_at.setter
|
|
179
|
+
def location_updated_at(self, value: int):
|
|
180
|
+
self.location.location_updated_at = value
|
|
181
|
+
|
|
182
|
+
# Group properties
|
|
183
|
+
@property
|
|
184
|
+
def group_id(self) -> bytes:
|
|
185
|
+
return self.group.group_id
|
|
186
|
+
|
|
187
|
+
@group_id.setter
|
|
188
|
+
def group_id(self, value: bytes):
|
|
189
|
+
self.group.group_id = value
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def group_label(self) -> str:
|
|
193
|
+
return self.group.group_label
|
|
194
|
+
|
|
195
|
+
@group_label.setter
|
|
196
|
+
def group_label(self, value: str):
|
|
197
|
+
self.group.group_label = value
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def group_updated_at(self) -> int:
|
|
201
|
+
return self.group.group_updated_at
|
|
202
|
+
|
|
203
|
+
@group_updated_at.setter
|
|
204
|
+
def group_updated_at(self, value: int):
|
|
205
|
+
self.group.group_updated_at = value
|
|
206
|
+
|
|
207
|
+
# Waveform properties
|
|
208
|
+
@property
|
|
209
|
+
def waveform_active(self) -> bool:
|
|
210
|
+
return self.waveform.waveform_active
|
|
211
|
+
|
|
212
|
+
@waveform_active.setter
|
|
213
|
+
def waveform_active(self, value: bool):
|
|
214
|
+
self.waveform.waveform_active = value
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def waveform_type(self) -> int:
|
|
218
|
+
return self.waveform.waveform_type
|
|
219
|
+
|
|
220
|
+
@waveform_type.setter
|
|
221
|
+
def waveform_type(self, value: int):
|
|
222
|
+
self.waveform.waveform_type = value
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def waveform_transient(self) -> bool:
|
|
226
|
+
return self.waveform.waveform_transient
|
|
227
|
+
|
|
228
|
+
@waveform_transient.setter
|
|
229
|
+
def waveform_transient(self, value: bool):
|
|
230
|
+
self.waveform.waveform_transient = value
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def waveform_color(self) -> LightHsbk:
|
|
234
|
+
return self.waveform.waveform_color
|
|
235
|
+
|
|
236
|
+
@waveform_color.setter
|
|
237
|
+
def waveform_color(self, value: LightHsbk):
|
|
238
|
+
self.waveform.waveform_color = value
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def waveform_period_ms(self) -> int:
|
|
242
|
+
return self.waveform.waveform_period_ms
|
|
243
|
+
|
|
244
|
+
@waveform_period_ms.setter
|
|
245
|
+
def waveform_period_ms(self, value: int):
|
|
246
|
+
self.waveform.waveform_period_ms = value
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def waveform_cycles(self) -> float:
|
|
250
|
+
return self.waveform.waveform_cycles
|
|
251
|
+
|
|
252
|
+
@waveform_cycles.setter
|
|
253
|
+
def waveform_cycles(self, value: float):
|
|
254
|
+
self.waveform.waveform_cycles = value
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def waveform_duty_cycle(self) -> int:
|
|
258
|
+
return self.waveform.waveform_duty_cycle
|
|
259
|
+
|
|
260
|
+
@waveform_duty_cycle.setter
|
|
261
|
+
def waveform_duty_cycle(self, value: int):
|
|
262
|
+
self.waveform.waveform_duty_cycle = value
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def waveform_skew_ratio(self) -> int:
|
|
266
|
+
return self.waveform.waveform_skew_ratio
|
|
267
|
+
|
|
268
|
+
@waveform_skew_ratio.setter
|
|
269
|
+
def waveform_skew_ratio(self, value: int):
|
|
270
|
+
self.waveform.waveform_skew_ratio = value
|
|
271
|
+
|
|
272
|
+
# Infrared properties
|
|
273
|
+
@property
|
|
274
|
+
def infrared_brightness(self) -> int:
|
|
275
|
+
if self.infrared is None:
|
|
276
|
+
return 0
|
|
277
|
+
return self.infrared.infrared_brightness
|
|
278
|
+
|
|
279
|
+
@infrared_brightness.setter
|
|
280
|
+
def infrared_brightness(self, value: int):
|
|
281
|
+
if self.infrared is not None:
|
|
282
|
+
self.infrared.infrared_brightness = value
|
|
283
|
+
|
|
284
|
+
# HEV properties
|
|
285
|
+
@property
|
|
286
|
+
def hev_cycle_duration_s(self) -> int:
|
|
287
|
+
if self.hev is None:
|
|
288
|
+
return 0
|
|
289
|
+
return self.hev.hev_cycle_duration_s
|
|
290
|
+
|
|
291
|
+
@hev_cycle_duration_s.setter
|
|
292
|
+
def hev_cycle_duration_s(self, value: int):
|
|
293
|
+
if self.hev is not None:
|
|
294
|
+
self.hev.hev_cycle_duration_s = value
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def hev_cycle_remaining_s(self) -> int:
|
|
298
|
+
if self.hev is None:
|
|
299
|
+
return 0
|
|
300
|
+
return self.hev.hev_cycle_remaining_s
|
|
301
|
+
|
|
302
|
+
@hev_cycle_remaining_s.setter
|
|
303
|
+
def hev_cycle_remaining_s(self, value: int):
|
|
304
|
+
if self.hev is not None:
|
|
305
|
+
self.hev.hev_cycle_remaining_s = value
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def hev_cycle_last_power(self) -> bool:
|
|
309
|
+
if self.hev is None:
|
|
310
|
+
return False
|
|
311
|
+
return self.hev.hev_cycle_last_power
|
|
312
|
+
|
|
313
|
+
@hev_cycle_last_power.setter
|
|
314
|
+
def hev_cycle_last_power(self, value: bool):
|
|
315
|
+
if self.hev is not None:
|
|
316
|
+
self.hev.hev_cycle_last_power = value
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def hev_indication(self) -> bool:
|
|
320
|
+
if self.hev is None:
|
|
321
|
+
return False
|
|
322
|
+
return self.hev.hev_indication
|
|
323
|
+
|
|
324
|
+
@hev_indication.setter
|
|
325
|
+
def hev_indication(self, value: bool):
|
|
326
|
+
if self.hev is not None:
|
|
327
|
+
self.hev.hev_indication = value
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def hev_last_result(self) -> int:
|
|
331
|
+
if self.hev is None:
|
|
332
|
+
return 0
|
|
333
|
+
return self.hev.hev_last_result
|
|
334
|
+
|
|
335
|
+
@hev_last_result.setter
|
|
336
|
+
def hev_last_result(self, value: int):
|
|
337
|
+
if self.hev is not None:
|
|
338
|
+
self.hev.hev_last_result = value
|
|
339
|
+
|
|
340
|
+
# MultiZone properties
|
|
341
|
+
@property
|
|
342
|
+
def zone_count(self) -> int:
|
|
343
|
+
if self.multizone is None:
|
|
344
|
+
return 0
|
|
345
|
+
return self.multizone.zone_count
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def zone_colors(self) -> list[LightHsbk]:
|
|
349
|
+
if self.multizone is None:
|
|
350
|
+
return []
|
|
351
|
+
return self.multizone.zone_colors
|
|
352
|
+
|
|
353
|
+
@zone_colors.setter
|
|
354
|
+
def zone_colors(self, value: list[LightHsbk]):
|
|
355
|
+
if self.multizone is not None:
|
|
356
|
+
self.multizone.zone_colors = value
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def multizone_effect_type(self) -> int:
|
|
360
|
+
if self.multizone is None:
|
|
361
|
+
return 0
|
|
362
|
+
return self.multizone.effect_type
|
|
363
|
+
|
|
364
|
+
@multizone_effect_type.setter
|
|
365
|
+
def multizone_effect_type(self, value: int):
|
|
366
|
+
if self.multizone is not None:
|
|
367
|
+
self.multizone.effect_type = value
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def multizone_effect_speed(self) -> int:
|
|
371
|
+
if self.multizone is None:
|
|
372
|
+
return 0
|
|
373
|
+
return self.multizone.effect_speed
|
|
374
|
+
|
|
375
|
+
@multizone_effect_speed.setter
|
|
376
|
+
def multizone_effect_speed(self, value: int):
|
|
377
|
+
if self.multizone is not None:
|
|
378
|
+
self.multizone.effect_speed = value
|
|
379
|
+
|
|
380
|
+
# Matrix (Tile) properties
|
|
381
|
+
@property
|
|
382
|
+
def tile_count(self) -> int:
|
|
383
|
+
if self.matrix is None:
|
|
384
|
+
return 0
|
|
385
|
+
return self.matrix.tile_count
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def tile_devices(self) -> list[dict[str, Any]]:
|
|
389
|
+
if self.matrix is None:
|
|
390
|
+
return []
|
|
391
|
+
return self.matrix.tile_devices
|
|
392
|
+
|
|
393
|
+
@tile_devices.setter
|
|
394
|
+
def tile_devices(self, value: list[dict[str, Any]]):
|
|
395
|
+
if self.matrix is not None:
|
|
396
|
+
self.matrix.tile_devices = value
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def tile_width(self) -> int:
|
|
400
|
+
if self.matrix is None:
|
|
401
|
+
return 8
|
|
402
|
+
return self.matrix.tile_width
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def tile_height(self) -> int:
|
|
406
|
+
if self.matrix is None:
|
|
407
|
+
return 8
|
|
408
|
+
return self.matrix.tile_height
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def tile_effect_type(self) -> int:
|
|
412
|
+
if self.matrix is None:
|
|
413
|
+
return 0
|
|
414
|
+
return self.matrix.effect_type
|
|
415
|
+
|
|
416
|
+
@tile_effect_type.setter
|
|
417
|
+
def tile_effect_type(self, value: int):
|
|
418
|
+
if self.matrix is not None:
|
|
419
|
+
self.matrix.effect_type = value
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def tile_effect_speed(self) -> int:
|
|
423
|
+
if self.matrix is None:
|
|
424
|
+
return 0
|
|
425
|
+
return self.matrix.effect_speed
|
|
426
|
+
|
|
427
|
+
@tile_effect_speed.setter
|
|
428
|
+
def tile_effect_speed(self, value: int):
|
|
429
|
+
if self.matrix is not None:
|
|
430
|
+
self.matrix.effect_speed = value
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def tile_effect_palette_count(self) -> int:
|
|
434
|
+
if self.matrix is None:
|
|
435
|
+
return 0
|
|
436
|
+
return self.matrix.effect_palette_count
|
|
437
|
+
|
|
438
|
+
@tile_effect_palette_count.setter
|
|
439
|
+
def tile_effect_palette_count(self, value: int):
|
|
440
|
+
if self.matrix is not None:
|
|
441
|
+
self.matrix.effect_palette_count = value
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def tile_effect_palette(self) -> list[LightHsbk]:
|
|
445
|
+
if self.matrix is None:
|
|
446
|
+
return []
|
|
447
|
+
return self.matrix.effect_palette
|
|
448
|
+
|
|
449
|
+
@tile_effect_palette.setter
|
|
450
|
+
def tile_effect_palette(self, value: list[LightHsbk]):
|
|
451
|
+
if self.matrix is not None:
|
|
452
|
+
self.matrix.effect_palette = value
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class EmulatedLifxDevice:
|
|
456
|
+
"""Emulated LIFX device with configurable scenarios"""
|
|
457
|
+
|
|
458
|
+
"""Simulated LIFX device with configurable scenarios"""
|
|
459
|
+
|
|
460
|
+
def __init__(
|
|
461
|
+
self,
|
|
462
|
+
device_state: DeviceState,
|
|
463
|
+
storage: AsyncDeviceStorage | None = None,
|
|
464
|
+
handler_registry: HandlerRegistry | None = None,
|
|
465
|
+
scenario_manager: HierarchicalScenarioManager | None = None,
|
|
466
|
+
):
|
|
467
|
+
self.state = device_state
|
|
468
|
+
# Use provided scenario manager or create a default empty one
|
|
469
|
+
if scenario_manager is not None:
|
|
470
|
+
self.scenario_manager = scenario_manager
|
|
471
|
+
else:
|
|
472
|
+
self.scenario_manager = HierarchicalScenarioManager()
|
|
473
|
+
self.start_time = time.time()
|
|
474
|
+
self.storage = storage
|
|
475
|
+
|
|
476
|
+
# Scenario caching for performance (HierarchicalScenarioManager only)
|
|
477
|
+
self._cached_scenario: ScenarioConfig | None = None
|
|
478
|
+
|
|
479
|
+
# Track background save tasks to prevent garbage collection
|
|
480
|
+
self.background_save_tasks: set[asyncio.Task] = set()
|
|
481
|
+
|
|
482
|
+
# Use provided registry or create default one
|
|
483
|
+
self.handlers = handler_registry or create_default_registry()
|
|
484
|
+
|
|
485
|
+
# Pre-allocate response header template for performance (10-15% gain)
|
|
486
|
+
# This avoids creating a new LifxHeader object for every response
|
|
487
|
+
self._response_header_template = LifxHeader(
|
|
488
|
+
source=0,
|
|
489
|
+
target=self.state.get_target_bytes(),
|
|
490
|
+
sequence=0,
|
|
491
|
+
tagged=False,
|
|
492
|
+
pkt_type=0,
|
|
493
|
+
size=0,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Initialize multizone colors if needed
|
|
497
|
+
# Note: State restoration is handled by StateRestorer in factories
|
|
498
|
+
if self.state.has_multizone and self.state.zone_count > 0:
|
|
499
|
+
if not self.state.zone_colors:
|
|
500
|
+
# Initialize with rainbow pattern
|
|
501
|
+
self.state.zone_colors = []
|
|
502
|
+
for i in range(self.state.zone_count):
|
|
503
|
+
hue = int((i / self.state.zone_count) * 65535)
|
|
504
|
+
self.state.zone_colors.append(
|
|
505
|
+
LightHsbk(
|
|
506
|
+
hue=hue, saturation=65535, brightness=32768, kelvin=3500
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Initialize tile state if needed
|
|
511
|
+
# Note: Saved tile data is restored by StateRestorer in factories
|
|
512
|
+
if self.state.has_matrix and self.state.tile_count > 0:
|
|
513
|
+
if not self.state.tile_devices:
|
|
514
|
+
for i in range(self.state.tile_count):
|
|
515
|
+
pixels = self.state.tile_width * self.state.tile_height
|
|
516
|
+
tile_colors = [
|
|
517
|
+
LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=3500)
|
|
518
|
+
for _ in range(pixels)
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
self.state.tile_devices.append(
|
|
522
|
+
{
|
|
523
|
+
"accel_meas_x": 0,
|
|
524
|
+
"accel_meas_y": 0,
|
|
525
|
+
"accel_meas_z": 0,
|
|
526
|
+
"user_x": float(i * self.state.tile_width),
|
|
527
|
+
"user_y": 0.0,
|
|
528
|
+
"width": self.state.tile_width,
|
|
529
|
+
"height": self.state.tile_height,
|
|
530
|
+
"device_version_vendor": 1,
|
|
531
|
+
"device_version_product": self.state.product,
|
|
532
|
+
"firmware_build": int(time.time()),
|
|
533
|
+
"firmware_version_minor": 70,
|
|
534
|
+
"firmware_version_major": 3,
|
|
535
|
+
"colors": tile_colors,
|
|
536
|
+
}
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Save initial state if persistence is enabled
|
|
540
|
+
# This ensures newly created devices are immediately persisted
|
|
541
|
+
if self.storage:
|
|
542
|
+
self._save_state()
|
|
543
|
+
|
|
544
|
+
def get_uptime_ns(self) -> int:
|
|
545
|
+
"""Calculate current uptime in nanoseconds"""
|
|
546
|
+
return int((time.time() - self.start_time) * 1e9)
|
|
547
|
+
|
|
548
|
+
def _save_state(self) -> None:
|
|
549
|
+
"""Save device state asynchronously (non-blocking).
|
|
550
|
+
|
|
551
|
+
Creates a background task to save state without blocking the event loop.
|
|
552
|
+
The task is tracked to prevent garbage collection.
|
|
553
|
+
|
|
554
|
+
Note: Only AsyncDeviceStorage is supported in production. For testing,
|
|
555
|
+
you can still use DeviceStorage, but it will log a warning as it blocks.
|
|
556
|
+
"""
|
|
557
|
+
if not self.storage:
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
loop = asyncio.get_running_loop()
|
|
562
|
+
task = loop.create_task(self.storage.save_device_state(self.state))
|
|
563
|
+
self._track_save_task(task)
|
|
564
|
+
except RuntimeError:
|
|
565
|
+
# No event loop (shouldn't happen in normal operation)
|
|
566
|
+
logger.error("Cannot save state for %s: no event loop", self.state.serial)
|
|
567
|
+
|
|
568
|
+
def _track_save_task(self, task: asyncio.Task) -> None:
|
|
569
|
+
"""Track background save task to prevent garbage collection.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
task: Save task to track
|
|
573
|
+
"""
|
|
574
|
+
self.background_save_tasks.add(task)
|
|
575
|
+
task.add_done_callback(self.background_save_tasks.discard)
|
|
576
|
+
|
|
577
|
+
def _get_resolved_scenario(self) -> ScenarioConfig:
|
|
578
|
+
"""Get resolved scenario configuration with caching.
|
|
579
|
+
|
|
580
|
+
Resolves scenario from all applicable scopes and caches the result
|
|
581
|
+
for performance.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
ScenarioConfig with resolved settings
|
|
585
|
+
"""
|
|
586
|
+
if self._cached_scenario is not None:
|
|
587
|
+
return self._cached_scenario
|
|
588
|
+
|
|
589
|
+
# Resolve scenario with hierarchical scoping
|
|
590
|
+
self._cached_scenario = self.scenario_manager.get_scenario_for_device(
|
|
591
|
+
serial=self.state.serial,
|
|
592
|
+
device_type=get_device_type(self),
|
|
593
|
+
location=self.state.location_label,
|
|
594
|
+
group=self.state.group_label,
|
|
595
|
+
)
|
|
596
|
+
return self._cached_scenario
|
|
597
|
+
|
|
598
|
+
def invalidate_scenario_cache(self) -> None:
|
|
599
|
+
"""Invalidate cached scenario configuration.
|
|
600
|
+
|
|
601
|
+
Call this when scenarios are updated at runtime to force
|
|
602
|
+
recalculation on the next packet.
|
|
603
|
+
"""
|
|
604
|
+
self._cached_scenario = None
|
|
605
|
+
|
|
606
|
+
def _create_response_header(
|
|
607
|
+
self, source: int, sequence: int, pkt_type: int, payload_size: int
|
|
608
|
+
) -> LifxHeader:
|
|
609
|
+
"""Create response header using pre-allocated template (performance).
|
|
610
|
+
|
|
611
|
+
This method uses a pre-allocated template and creates a shallow copy,
|
|
612
|
+
then updates the fields. This avoids full __init__ and __post_init__
|
|
613
|
+
overhead while ensuring each response gets its own header object,
|
|
614
|
+
providing ~10% improvement in response generation.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
source: Source identifier from request
|
|
618
|
+
sequence: Sequence number from request
|
|
619
|
+
pkt_type: Packet type for response
|
|
620
|
+
payload_size: Size of packed payload in bytes
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
Configured LifxHeader ready to use
|
|
624
|
+
"""
|
|
625
|
+
# Shallow copy of template is faster than full construction with validation
|
|
626
|
+
header = copy.copy(self._response_header_template)
|
|
627
|
+
# Update fields for this specific response
|
|
628
|
+
header.source = source
|
|
629
|
+
header.sequence = sequence
|
|
630
|
+
header.pkt_type = pkt_type
|
|
631
|
+
header.size = LIFX_HEADER_SIZE + payload_size
|
|
632
|
+
return header
|
|
633
|
+
|
|
634
|
+
def process_packet(
|
|
635
|
+
self, header: LifxHeader, packet: Any | None
|
|
636
|
+
) -> list[tuple[LifxHeader, Any]]:
|
|
637
|
+
"""Process incoming packet and return response packets"""
|
|
638
|
+
responses = []
|
|
639
|
+
|
|
640
|
+
# Get resolved scenario configuration (cached for performance)
|
|
641
|
+
scenario = self._get_resolved_scenario()
|
|
642
|
+
|
|
643
|
+
# Check if packet should be dropped (with probabilistic drops)
|
|
644
|
+
if not self.scenario_manager.should_respond(header.pkt_type, scenario):
|
|
645
|
+
logger.info("Dropping packet type %s per scenario", header.pkt_type)
|
|
646
|
+
return responses
|
|
647
|
+
|
|
648
|
+
# Update uptime
|
|
649
|
+
self.state.uptime_ns = self.get_uptime_ns()
|
|
650
|
+
|
|
651
|
+
# Handle acknowledgment (packet type 45, no payload)
|
|
652
|
+
if header.ack_required:
|
|
653
|
+
ack_packet = Device.Acknowledgement()
|
|
654
|
+
ack_header = self._create_response_header(
|
|
655
|
+
header.source,
|
|
656
|
+
header.sequence,
|
|
657
|
+
ack_packet.PKT_TYPE,
|
|
658
|
+
len(ack_packet.pack()),
|
|
659
|
+
)
|
|
660
|
+
responses.append((ack_header, ack_packet))
|
|
661
|
+
|
|
662
|
+
# Handle specific packet types - handlers always return list
|
|
663
|
+
response_packets = self._handle_packet_type(header, packet)
|
|
664
|
+
# Handlers now always return list (empty if no response)
|
|
665
|
+
for resp_packet in response_packets:
|
|
666
|
+
resp_header = self._create_response_header(
|
|
667
|
+
header.source,
|
|
668
|
+
header.sequence,
|
|
669
|
+
resp_packet.PKT_TYPE,
|
|
670
|
+
len(resp_packet.pack()),
|
|
671
|
+
)
|
|
672
|
+
responses.append((resp_header, resp_packet))
|
|
673
|
+
|
|
674
|
+
# Apply error scenarios to responses
|
|
675
|
+
modified_responses = []
|
|
676
|
+
for resp_header, resp_packet in responses:
|
|
677
|
+
# Check if we should send malformed packet (truncate payload)
|
|
678
|
+
if resp_header.pkt_type in scenario.malformed_packets:
|
|
679
|
+
# For malformed packets, we'll pack it first then truncate
|
|
680
|
+
resp_payload = resp_packet.pack() if resp_packet else b""
|
|
681
|
+
truncated_len = len(resp_payload) // 2
|
|
682
|
+
resp_payload = resp_payload[:truncated_len]
|
|
683
|
+
resp_header.size = LIFX_HEADER_SIZE + truncated_len + 10 # Wrong size
|
|
684
|
+
# Convert back to bytes for malformed case
|
|
685
|
+
modified_responses.append((resp_header, resp_payload))
|
|
686
|
+
logger.info(
|
|
687
|
+
"Sending malformed packet type %s (truncated)", resp_header.pkt_type
|
|
688
|
+
)
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
# Check if we should send invalid field values
|
|
692
|
+
if resp_header.pkt_type in scenario.invalid_field_values:
|
|
693
|
+
# Pack normally then corrupt the bytes
|
|
694
|
+
resp_payload = resp_packet.pack() if resp_packet else b""
|
|
695
|
+
resp_payload = b"\xff" * len(resp_payload)
|
|
696
|
+
modified_responses.append((resp_header, resp_payload))
|
|
697
|
+
pkt_type = resp_header.pkt_type
|
|
698
|
+
logger.info("Sending invalid field values for packet type %s", pkt_type)
|
|
699
|
+
continue
|
|
700
|
+
|
|
701
|
+
modified_responses.append((resp_header, resp_packet))
|
|
702
|
+
|
|
703
|
+
return modified_responses
|
|
704
|
+
|
|
705
|
+
def _handle_packet_type(self, header: LifxHeader, packet: Any | None) -> list[Any]:
|
|
706
|
+
"""Handle specific packet types using registered handlers.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
List of response packets (empty list if no response)
|
|
710
|
+
"""
|
|
711
|
+
pkt_type = header.pkt_type
|
|
712
|
+
|
|
713
|
+
# Update uptime for this packet
|
|
714
|
+
self.state.uptime_ns = self.get_uptime_ns()
|
|
715
|
+
|
|
716
|
+
# Find handler for this packet type
|
|
717
|
+
handler = self.handlers.get_handler(pkt_type)
|
|
718
|
+
|
|
719
|
+
if handler:
|
|
720
|
+
# Delegate to handler (always returns list now)
|
|
721
|
+
response = handler.handle(self.state, packet, header.res_required)
|
|
722
|
+
|
|
723
|
+
# Save state if storage is enabled (for SET operations)
|
|
724
|
+
if packet and self.storage:
|
|
725
|
+
self._save_state()
|
|
726
|
+
|
|
727
|
+
return response
|
|
728
|
+
else:
|
|
729
|
+
# Unknown/unimplemented packet type
|
|
730
|
+
from lifx_emulator.protocol.packets import get_packet_class
|
|
731
|
+
|
|
732
|
+
packet_class = get_packet_class(pkt_type)
|
|
733
|
+
if packet_class:
|
|
734
|
+
logger.info(
|
|
735
|
+
"Device %s: Received %s (type %s) but no handler registered",
|
|
736
|
+
self.state.serial,
|
|
737
|
+
packet_class.__qualname__,
|
|
738
|
+
pkt_type,
|
|
739
|
+
)
|
|
740
|
+
else:
|
|
741
|
+
serial = self.state.serial
|
|
742
|
+
logger.warning(
|
|
743
|
+
"Device %s: Received unknown packet type %s", serial, pkt_type
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Check scenario for StateUnhandled response
|
|
747
|
+
scenario = self._get_resolved_scenario()
|
|
748
|
+
if scenario.send_unhandled:
|
|
749
|
+
return [Device.StateUnhandled(unhandled_type=pkt_type)]
|
|
750
|
+
return []
|