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,334 @@
|
|
|
1
|
+
"""Base packet class for LIFX protocol.
|
|
2
|
+
|
|
3
|
+
Provides generic pack/unpack functionality for all packet types.
|
|
4
|
+
|
|
5
|
+
Performance optimizations:
|
|
6
|
+
- Pre-compiled regex patterns for field name conversion
|
|
7
|
+
- Cached field type parsing results
|
|
8
|
+
- Pre-computed field info for faster unpacking
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, ClassVar
|
|
16
|
+
|
|
17
|
+
# Performance optimization: Pre-compiled regex patterns
|
|
18
|
+
_CAMEL_TO_SNAKE_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")
|
|
19
|
+
_ARRAY_TYPE_PATTERN = re.compile(r"\[(\d+)\](.+)")
|
|
20
|
+
|
|
21
|
+
# Performance optimization: Field type parsing cache
|
|
22
|
+
_FIELD_TYPE_CACHE: dict[str, tuple[str, int | None, bool]] = {}
|
|
23
|
+
|
|
24
|
+
# Performance optimization: Field name conversion cache (per-class)
|
|
25
|
+
_FIELD_NAME_CACHE: dict[type, dict[str, str]] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Packet:
|
|
30
|
+
"""Base class for all LIFX protocol packets.
|
|
31
|
+
|
|
32
|
+
Each packet subclass defines:
|
|
33
|
+
- PKT_TYPE: ClassVar[int] - The packet type number
|
|
34
|
+
- _fields: ClassVar[list[dict]] - Field metadata from protocol.yml
|
|
35
|
+
- Actual field attributes as dataclass fields
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
PKT_TYPE: ClassVar[int]
|
|
39
|
+
_fields: ClassVar[list[dict[str, Any]]]
|
|
40
|
+
_field_info: ClassVar[list[tuple[str, str, int]] | None] = None
|
|
41
|
+
|
|
42
|
+
def pack(self) -> bytes:
|
|
43
|
+
"""Pack packet to bytes using field metadata.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Packed bytes ready to send in a LIFX message payload
|
|
47
|
+
"""
|
|
48
|
+
from lifx_emulator.protocol import serializer
|
|
49
|
+
|
|
50
|
+
result = b""
|
|
51
|
+
|
|
52
|
+
for field_item in self._fields:
|
|
53
|
+
# Handle reserved fields (no name)
|
|
54
|
+
if "name" not in field_item:
|
|
55
|
+
size_bytes = field_item.get("size_bytes", 0)
|
|
56
|
+
result += serializer.pack_reserved(size_bytes)
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
# Get field value from instance
|
|
60
|
+
field_name = self._protocol_to_python_name(field_item["name"])
|
|
61
|
+
value = getattr(self, field_name)
|
|
62
|
+
|
|
63
|
+
# Pack based on type
|
|
64
|
+
field_type = field_item["type"]
|
|
65
|
+
size_bytes = field_item.get("size_bytes", 0)
|
|
66
|
+
result += self._pack_field_value(value, field_type, size_bytes)
|
|
67
|
+
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def unpack(cls, data: bytes, offset: int = 0) -> Packet:
|
|
72
|
+
"""Unpack packet from bytes using field metadata.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
data: Bytes to unpack from
|
|
76
|
+
offset: Offset in bytes to start unpacking
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Packet instance
|
|
80
|
+
"""
|
|
81
|
+
packet, _ = cls._unpack_internal(data, offset)
|
|
82
|
+
return packet
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def _compute_field_info(cls) -> list[tuple[str, str, int]]:
|
|
86
|
+
"""Pre-compute parsed field metadata for faster unpacking.
|
|
87
|
+
|
|
88
|
+
This optimization caches the parsing of field metadata to avoid
|
|
89
|
+
repeated dictionary lookups and name conversions during unpacking.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of tuples: (field_name, field_type, size_bytes)
|
|
93
|
+
Reserved fields have empty string as field_name
|
|
94
|
+
"""
|
|
95
|
+
info: list[tuple[str, str, int]] = []
|
|
96
|
+
|
|
97
|
+
for field_item in cls._fields:
|
|
98
|
+
size_bytes = field_item.get("size_bytes", 0)
|
|
99
|
+
|
|
100
|
+
# Handle reserved fields (no name)
|
|
101
|
+
if "name" not in field_item:
|
|
102
|
+
info.append(("", "", size_bytes))
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
# Regular field
|
|
106
|
+
field_name = cls._protocol_to_python_name(field_item["name"])
|
|
107
|
+
field_type = field_item["type"]
|
|
108
|
+
info.append((field_name, field_type, size_bytes))
|
|
109
|
+
|
|
110
|
+
return info
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def _unpack_internal(cls, data: bytes, offset: int) -> tuple[Packet, int]:
|
|
114
|
+
"""Internal method for unpacking packets with offset tracking.
|
|
115
|
+
|
|
116
|
+
This method handles the recursion needed for nested structures.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
data: Bytes to unpack from
|
|
120
|
+
offset: Offset in bytes to start unpacking
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Tuple of (packet_instance, new_offset)
|
|
124
|
+
"""
|
|
125
|
+
# Pre-compute field info on first use (lazy initialization)
|
|
126
|
+
if cls._field_info is None:
|
|
127
|
+
cls._field_info = cls._compute_field_info()
|
|
128
|
+
|
|
129
|
+
current_offset = offset
|
|
130
|
+
field_values: dict[str, Any] = {}
|
|
131
|
+
|
|
132
|
+
for field_name, field_type, size_bytes in cls._field_info:
|
|
133
|
+
# Handle reserved fields (empty name)
|
|
134
|
+
if not field_name:
|
|
135
|
+
current_offset += size_bytes
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Unpack field value
|
|
139
|
+
value, current_offset = cls._unpack_field_value(
|
|
140
|
+
data, field_type, size_bytes, current_offset
|
|
141
|
+
)
|
|
142
|
+
field_values[field_name] = value
|
|
143
|
+
|
|
144
|
+
return cls(**field_values), current_offset
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def _protocol_to_python_name(cls, name: str) -> str:
|
|
148
|
+
"""Convert protocol name (PascalCase) to Python name (snake_case).
|
|
149
|
+
|
|
150
|
+
Performance optimized with caching and pre-compiled regex.
|
|
151
|
+
"""
|
|
152
|
+
# Check class-specific cache first
|
|
153
|
+
if cls not in _FIELD_NAME_CACHE:
|
|
154
|
+
_FIELD_NAME_CACHE[cls] = {}
|
|
155
|
+
|
|
156
|
+
cache = _FIELD_NAME_CACHE[cls]
|
|
157
|
+
|
|
158
|
+
if name not in cache:
|
|
159
|
+
# Convert using pre-compiled pattern
|
|
160
|
+
snake = _CAMEL_TO_SNAKE_PATTERN.sub("_", name)
|
|
161
|
+
cache[name] = snake.lower()
|
|
162
|
+
|
|
163
|
+
return cache[name]
|
|
164
|
+
|
|
165
|
+
def _pack_field_value(self, value: Any, field_type: str, size_bytes: int) -> bytes:
|
|
166
|
+
"""Pack a single field value based on its type."""
|
|
167
|
+
from lifx_emulator.protocol import serializer
|
|
168
|
+
|
|
169
|
+
# Parse field type
|
|
170
|
+
base_type, array_count, is_nested = self._parse_field_type(field_type)
|
|
171
|
+
|
|
172
|
+
# Check if it's an enum (Button/Relay enums excluded)
|
|
173
|
+
enum_types = {
|
|
174
|
+
"DeviceService",
|
|
175
|
+
"LightLastHevCycleResult",
|
|
176
|
+
"LightWaveform",
|
|
177
|
+
"MultiZoneApplicationRequest",
|
|
178
|
+
"MultiZoneEffectType",
|
|
179
|
+
"MultiZoneExtendedApplicationRequest",
|
|
180
|
+
}
|
|
181
|
+
is_enum = is_nested and base_type in enum_types
|
|
182
|
+
|
|
183
|
+
# Handle different field types
|
|
184
|
+
if array_count:
|
|
185
|
+
if is_enum:
|
|
186
|
+
# Array of enums
|
|
187
|
+
result = b""
|
|
188
|
+
for item in value:
|
|
189
|
+
result += serializer.pack_value(int(item), "uint8")
|
|
190
|
+
return result
|
|
191
|
+
elif is_nested:
|
|
192
|
+
# Array of nested structures
|
|
193
|
+
result = b""
|
|
194
|
+
for item in value:
|
|
195
|
+
result += item.pack()
|
|
196
|
+
return result
|
|
197
|
+
elif base_type in ("uint8", "byte"):
|
|
198
|
+
# Byte array
|
|
199
|
+
return serializer.pack_bytes(value, size_bytes)
|
|
200
|
+
else:
|
|
201
|
+
# Array of primitives
|
|
202
|
+
return serializer.pack_array(value, base_type, array_count)
|
|
203
|
+
elif is_enum:
|
|
204
|
+
# Single enum
|
|
205
|
+
return serializer.pack_value(int(value), "uint8")
|
|
206
|
+
elif is_nested:
|
|
207
|
+
# Nested structure
|
|
208
|
+
return value.pack()
|
|
209
|
+
else:
|
|
210
|
+
# Primitive type
|
|
211
|
+
return serializer.pack_value(value, base_type)
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def _unpack_field_value(
|
|
215
|
+
cls, data: bytes, field_type: str, size_bytes: int, offset: int
|
|
216
|
+
) -> tuple[Any, int]:
|
|
217
|
+
"""Unpack a single field value based on its type."""
|
|
218
|
+
from lifx_emulator.protocol import serializer
|
|
219
|
+
from lifx_emulator.protocol.protocol_types import (
|
|
220
|
+
DeviceService,
|
|
221
|
+
LightLastHevCycleResult,
|
|
222
|
+
LightWaveform,
|
|
223
|
+
MultiZoneApplicationRequest,
|
|
224
|
+
MultiZoneEffectType,
|
|
225
|
+
MultiZoneExtendedApplicationRequest,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Parse field type
|
|
229
|
+
base_type, array_count, is_nested = cls._parse_field_type(field_type)
|
|
230
|
+
|
|
231
|
+
# Check if it's an enum (Button/Relay enums excluded)
|
|
232
|
+
enum_types = {
|
|
233
|
+
"DeviceService": DeviceService,
|
|
234
|
+
"LightLastHevCycleResult": LightLastHevCycleResult,
|
|
235
|
+
"LightWaveform": LightWaveform,
|
|
236
|
+
"MultiZoneApplicationRequest": MultiZoneApplicationRequest,
|
|
237
|
+
"MultiZoneEffectType": MultiZoneEffectType,
|
|
238
|
+
"MultiZoneExtendedApplicationRequest": MultiZoneExtendedApplicationRequest,
|
|
239
|
+
}
|
|
240
|
+
is_enum = is_nested and base_type in enum_types
|
|
241
|
+
|
|
242
|
+
# Handle different field types
|
|
243
|
+
if array_count:
|
|
244
|
+
if is_enum:
|
|
245
|
+
# Array of enums
|
|
246
|
+
result = []
|
|
247
|
+
current_offset = offset
|
|
248
|
+
enum_class = enum_types[base_type]
|
|
249
|
+
for _ in range(array_count):
|
|
250
|
+
item_raw, current_offset = serializer.unpack_value(
|
|
251
|
+
data, "uint8", current_offset
|
|
252
|
+
)
|
|
253
|
+
result.append(enum_class(item_raw))
|
|
254
|
+
return result, current_offset
|
|
255
|
+
elif is_nested:
|
|
256
|
+
# Array of nested structures - need to import dynamically
|
|
257
|
+
from lifx_emulator.protocol import protocol_types
|
|
258
|
+
|
|
259
|
+
struct_class = getattr(protocol_types, base_type)
|
|
260
|
+
result = []
|
|
261
|
+
current_offset = offset
|
|
262
|
+
for _ in range(array_count):
|
|
263
|
+
# Check if it's a Packet subclass or protocol_types class
|
|
264
|
+
if issubclass(struct_class, cls):
|
|
265
|
+
item, current_offset = struct_class._unpack_internal(
|
|
266
|
+
data, current_offset
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
item_result = struct_class.unpack(data, current_offset)
|
|
270
|
+
item, current_offset = item_result # type: ignore[misc]
|
|
271
|
+
result.append(item)
|
|
272
|
+
return result, current_offset
|
|
273
|
+
elif base_type in ("uint8", "byte"):
|
|
274
|
+
# Byte array
|
|
275
|
+
return serializer.unpack_bytes(data, size_bytes, offset)
|
|
276
|
+
else:
|
|
277
|
+
# Array of primitives
|
|
278
|
+
return serializer.unpack_array(data, base_type, array_count, offset)
|
|
279
|
+
elif is_enum:
|
|
280
|
+
# Single enum
|
|
281
|
+
enum_class = enum_types[base_type]
|
|
282
|
+
value_raw, new_offset = serializer.unpack_value(data, "uint8", offset)
|
|
283
|
+
return enum_class(value_raw), new_offset
|
|
284
|
+
elif is_nested:
|
|
285
|
+
# Nested structure - import dynamically
|
|
286
|
+
from lifx_emulator.protocol import protocol_types
|
|
287
|
+
|
|
288
|
+
struct_class = getattr(protocol_types, base_type)
|
|
289
|
+
# Check if it's a Packet subclass or protocol_types class
|
|
290
|
+
if issubclass(struct_class, cls):
|
|
291
|
+
return struct_class._unpack_internal(data, offset)
|
|
292
|
+
else:
|
|
293
|
+
return struct_class.unpack(data, offset)
|
|
294
|
+
else:
|
|
295
|
+
# Primitive type
|
|
296
|
+
return serializer.unpack_value(data, base_type, offset)
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _parse_field_type(field_type: str) -> tuple[str, int | None, bool]:
|
|
300
|
+
"""Parse a field type string with caching.
|
|
301
|
+
|
|
302
|
+
Performance optimized with global cache and pre-compiled regex.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
field_type: Field type (e.g., 'uint16', '[32]uint8', '<HSBK>')
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Tuple of (base_type, array_count, is_nested)
|
|
309
|
+
"""
|
|
310
|
+
# Check cache first
|
|
311
|
+
if field_type in _FIELD_TYPE_CACHE:
|
|
312
|
+
return _FIELD_TYPE_CACHE[field_type]
|
|
313
|
+
|
|
314
|
+
# Parse type
|
|
315
|
+
# Check for array: [N]type
|
|
316
|
+
array_match = _ARRAY_TYPE_PATTERN.match(field_type)
|
|
317
|
+
if array_match:
|
|
318
|
+
count = int(array_match.group(1))
|
|
319
|
+
inner_type = array_match.group(2)
|
|
320
|
+
# Check if inner type is nested
|
|
321
|
+
if inner_type.startswith("<") and inner_type.endswith(">"):
|
|
322
|
+
result = (inner_type[1:-1], count, True)
|
|
323
|
+
else:
|
|
324
|
+
result = (inner_type, count, False)
|
|
325
|
+
# Check for nested structure: <Type>
|
|
326
|
+
elif field_type.startswith("<") and field_type.endswith(">"):
|
|
327
|
+
result = (field_type[1:-1], None, True)
|
|
328
|
+
else:
|
|
329
|
+
# Simple type
|
|
330
|
+
result = (field_type, None, False)
|
|
331
|
+
|
|
332
|
+
# Cache the result
|
|
333
|
+
_FIELD_TYPE_CACHE[field_type] = result
|
|
334
|
+
return result
|