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
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from dataclasses import dataclass
|
|
13
13
|
from enum import IntEnum
|
|
14
|
+
from functools import cached_property
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class ProductCapability(IntEnum):
|
|
@@ -128,6 +129,53 @@ class ProductInfo:
|
|
|
128
129
|
return True
|
|
129
130
|
return firmware_version >= self.min_ext_mz_firmware
|
|
130
131
|
|
|
132
|
+
@cached_property
|
|
133
|
+
def caps(self) -> str:
|
|
134
|
+
"""Format product capabilities as a human-readable string.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Comma-separated capability string (e.g., "full color, infrared, multizone")
|
|
138
|
+
"""
|
|
139
|
+
caps = []
|
|
140
|
+
|
|
141
|
+
# Determine base light type
|
|
142
|
+
if self.has_relays:
|
|
143
|
+
# Devices with relays are switches, not lights
|
|
144
|
+
caps.append("switch")
|
|
145
|
+
elif self.has_color:
|
|
146
|
+
caps.append("full color")
|
|
147
|
+
else:
|
|
148
|
+
# Check temperature range to determine white light type
|
|
149
|
+
if self.temperature_range:
|
|
150
|
+
if self.temperature_range.min != self.temperature_range.max:
|
|
151
|
+
caps.append("color temperature")
|
|
152
|
+
else:
|
|
153
|
+
caps.append("brightness only")
|
|
154
|
+
else:
|
|
155
|
+
# No temperature range info, assume basic brightness
|
|
156
|
+
caps.append("brightness only")
|
|
157
|
+
|
|
158
|
+
# Add additional capabilities
|
|
159
|
+
if self.has_infrared:
|
|
160
|
+
caps.append("infrared")
|
|
161
|
+
# Extended multizone is backwards compatible with multizone,
|
|
162
|
+
# so only show multizone if extended multizone is not present
|
|
163
|
+
if self.has_extended_multizone:
|
|
164
|
+
caps.append("extended-multizone")
|
|
165
|
+
elif self.has_multizone:
|
|
166
|
+
caps.append("multizone")
|
|
167
|
+
if self.has_matrix:
|
|
168
|
+
caps.append("matrix")
|
|
169
|
+
if self.has_hev:
|
|
170
|
+
caps.append("HEV")
|
|
171
|
+
if self.has_chain:
|
|
172
|
+
caps.append("chain")
|
|
173
|
+
if self.has_buttons and not self.has_relays:
|
|
174
|
+
# Only show buttons if not already identified as switch
|
|
175
|
+
caps.append("buttons")
|
|
176
|
+
|
|
177
|
+
return ", ".join(caps) if caps else "unknown"
|
|
178
|
+
|
|
131
179
|
|
|
132
180
|
# Pre-generated product definitions
|
|
133
181
|
PRODUCTS: dict[int, ProductInfo] = {
|
|
@@ -457,22 +505,6 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
457
505
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
458
506
|
min_ext_mz_firmware=None,
|
|
459
507
|
),
|
|
460
|
-
70: ProductInfo(
|
|
461
|
-
pid=70,
|
|
462
|
-
name="LIFX Switch",
|
|
463
|
-
vendor=1,
|
|
464
|
-
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
465
|
-
temperature_range=None,
|
|
466
|
-
min_ext_mz_firmware=None,
|
|
467
|
-
),
|
|
468
|
-
71: ProductInfo(
|
|
469
|
-
pid=71,
|
|
470
|
-
name="LIFX Switch",
|
|
471
|
-
vendor=1,
|
|
472
|
-
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
473
|
-
temperature_range=None,
|
|
474
|
-
min_ext_mz_firmware=None,
|
|
475
|
-
),
|
|
476
508
|
81: ProductInfo(
|
|
477
509
|
pid=81,
|
|
478
510
|
name="LIFX Candle White to Warm",
|
|
@@ -513,14 +545,6 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
513
545
|
temperature_range=TemperatureRange(min=2700, max=2700),
|
|
514
546
|
min_ext_mz_firmware=None,
|
|
515
547
|
),
|
|
516
|
-
89: ProductInfo(
|
|
517
|
-
pid=89,
|
|
518
|
-
name="LIFX Switch",
|
|
519
|
-
vendor=1,
|
|
520
|
-
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
521
|
-
temperature_range=None,
|
|
522
|
-
min_ext_mz_firmware=None,
|
|
523
|
-
),
|
|
524
548
|
90: ProductInfo(
|
|
525
549
|
pid=90,
|
|
526
550
|
name="LIFX Clean",
|
|
@@ -657,22 +681,6 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
657
681
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
658
682
|
min_ext_mz_firmware=None,
|
|
659
683
|
),
|
|
660
|
-
115: ProductInfo(
|
|
661
|
-
pid=115,
|
|
662
|
-
name="LIFX Switch",
|
|
663
|
-
vendor=1,
|
|
664
|
-
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
665
|
-
temperature_range=None,
|
|
666
|
-
min_ext_mz_firmware=None,
|
|
667
|
-
),
|
|
668
|
-
116: ProductInfo(
|
|
669
|
-
pid=116,
|
|
670
|
-
name="LIFX Switch",
|
|
671
|
-
vendor=1,
|
|
672
|
-
capabilities=ProductCapability.RELAYS | ProductCapability.BUTTONS,
|
|
673
|
-
temperature_range=None,
|
|
674
|
-
min_ext_mz_firmware=None,
|
|
675
|
-
),
|
|
676
684
|
117: ProductInfo(
|
|
677
685
|
pid=117,
|
|
678
686
|
name="LIFX Z US",
|
|
@@ -1313,6 +1321,10 @@ class ProductRegistry:
|
|
|
1313
1321
|
prod_features = product.get("features", {})
|
|
1314
1322
|
features: dict[str, Any] = {**default_features, **prod_features}
|
|
1315
1323
|
|
|
1324
|
+
# Skip switch products (devices with relays) - these are not lights
|
|
1325
|
+
if features.get("relays"):
|
|
1326
|
+
continue
|
|
1327
|
+
|
|
1316
1328
|
# Build capabilities bitfield
|
|
1317
1329
|
capabilities = 0
|
|
1318
1330
|
if features.get("color"):
|
lifx_emulator/products/specs.py
CHANGED
|
@@ -83,19 +83,18 @@ class SpecsRegistry:
|
|
|
83
83
|
|
|
84
84
|
# Parse product specs
|
|
85
85
|
for pid, specs_data in data["products"].items():
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
86
|
+
self._specs[int(pid)] = ProductSpecs(
|
|
87
|
+
product_id=int(pid),
|
|
88
|
+
default_zone_count=specs_data.get("default_zone_count"),
|
|
89
|
+
min_zone_count=specs_data.get("min_zone_count"),
|
|
90
|
+
max_zone_count=specs_data.get("max_zone_count"),
|
|
91
|
+
default_tile_count=specs_data.get("default_tile_count"),
|
|
92
|
+
min_tile_count=specs_data.get("min_tile_count"),
|
|
93
|
+
max_tile_count=specs_data.get("max_tile_count"),
|
|
94
|
+
tile_width=specs_data.get("tile_width"),
|
|
95
|
+
tile_height=specs_data.get("tile_height"),
|
|
96
|
+
notes=specs_data.get("notes"),
|
|
97
|
+
)
|
|
99
98
|
|
|
100
99
|
self._loaded = True
|
|
101
100
|
|
lifx_emulator/protocol/base.py
CHANGED
|
@@ -10,10 +10,13 @@ Performance optimizations:
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import logging
|
|
13
14
|
import re
|
|
14
|
-
from dataclasses import dataclass
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
15
16
|
from typing import Any, ClassVar
|
|
16
17
|
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
17
20
|
# Performance optimization: Pre-compiled regex patterns
|
|
18
21
|
_CAMEL_TO_SNAKE_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")
|
|
19
22
|
_ARRAY_TYPE_PATTERN = re.compile(r"\[(\d+)\](.+)")
|
|
@@ -39,6 +42,11 @@ class Packet:
|
|
|
39
42
|
_fields: ClassVar[list[dict[str, Any]]]
|
|
40
43
|
_field_info: ClassVar[list[tuple[str, str, int]] | None] = None
|
|
41
44
|
|
|
45
|
+
@property
|
|
46
|
+
def as_dict(self) -> dict[str, Any]:
|
|
47
|
+
"""Return packet as dictionary."""
|
|
48
|
+
return asdict(self)
|
|
49
|
+
|
|
42
50
|
def pack(self) -> bytes:
|
|
43
51
|
"""Pack packet to bytes using field metadata.
|
|
44
52
|
|
|
@@ -76,9 +84,25 @@ class Packet:
|
|
|
76
84
|
offset: Offset in bytes to start unpacking
|
|
77
85
|
|
|
78
86
|
Returns:
|
|
79
|
-
Packet instance
|
|
87
|
+
Packet instance with label fields decoded to strings
|
|
80
88
|
"""
|
|
81
89
|
packet, _ = cls._unpack_internal(data, offset)
|
|
90
|
+
|
|
91
|
+
# Decode label fields from bytes to string in-place
|
|
92
|
+
# This ensures all State packets have human-readable labels
|
|
93
|
+
cls._decode_labels_inplace(packet)
|
|
94
|
+
|
|
95
|
+
# Log packet values after unpacking and decoding labels
|
|
96
|
+
packet_values = asdict(packet)
|
|
97
|
+
_LOGGER.debug(
|
|
98
|
+
{
|
|
99
|
+
"class": "Packet",
|
|
100
|
+
"method": "unpack",
|
|
101
|
+
"packet_type": type(packet).__name__,
|
|
102
|
+
"values": packet_values,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
82
106
|
return packet
|
|
83
107
|
|
|
84
108
|
@classmethod
|
|
@@ -137,7 +161,7 @@ class Packet:
|
|
|
137
161
|
|
|
138
162
|
# Unpack field value
|
|
139
163
|
value, current_offset = cls._unpack_field_value(
|
|
140
|
-
data, field_type, size_bytes, current_offset
|
|
164
|
+
data, field_type, size_bytes, current_offset, field_name
|
|
141
165
|
)
|
|
142
166
|
field_values[field_name] = value
|
|
143
167
|
|
|
@@ -177,6 +201,9 @@ class Packet:
|
|
|
177
201
|
"MultiZoneApplicationRequest",
|
|
178
202
|
"MultiZoneEffectType",
|
|
179
203
|
"MultiZoneExtendedApplicationRequest",
|
|
204
|
+
"TileEffectSkyPalette",
|
|
205
|
+
"TileEffectSkyType",
|
|
206
|
+
"TileEffectType",
|
|
180
207
|
}
|
|
181
208
|
is_enum = is_nested and base_type in enum_types
|
|
182
209
|
|
|
@@ -195,7 +222,10 @@ class Packet:
|
|
|
195
222
|
result += item.pack()
|
|
196
223
|
return result
|
|
197
224
|
elif base_type in ("uint8", "byte"):
|
|
198
|
-
#
|
|
225
|
+
# Check if value is a string (Label fields)
|
|
226
|
+
if isinstance(value, str):
|
|
227
|
+
return serializer.pack_string(value, size_bytes)
|
|
228
|
+
# Regular byte array
|
|
199
229
|
return serializer.pack_bytes(value, size_bytes)
|
|
200
230
|
else:
|
|
201
231
|
# Array of primitives
|
|
@@ -211,11 +241,105 @@ class Packet:
|
|
|
211
241
|
return serializer.pack_value(value, base_type)
|
|
212
242
|
|
|
213
243
|
@classmethod
|
|
214
|
-
def
|
|
215
|
-
cls,
|
|
244
|
+
def _unpack_array_field(
|
|
245
|
+
cls,
|
|
246
|
+
data: bytes,
|
|
247
|
+
base_type: str,
|
|
248
|
+
array_count: int,
|
|
249
|
+
size_bytes: int,
|
|
250
|
+
offset: int,
|
|
251
|
+
field_name: str,
|
|
252
|
+
is_nested: bool,
|
|
253
|
+
enum_types: dict,
|
|
254
|
+
) -> tuple[Any, int]:
|
|
255
|
+
"""Unpack an array field value."""
|
|
256
|
+
from lifx_emulator.protocol import serializer
|
|
257
|
+
|
|
258
|
+
is_enum = is_nested and base_type in enum_types
|
|
259
|
+
|
|
260
|
+
if is_enum:
|
|
261
|
+
result = []
|
|
262
|
+
current_offset = offset
|
|
263
|
+
enum_class = enum_types[base_type]
|
|
264
|
+
for _ in range(array_count):
|
|
265
|
+
item_raw, current_offset = serializer.unpack_value(
|
|
266
|
+
data, "uint8", current_offset
|
|
267
|
+
)
|
|
268
|
+
result.append(enum_class(item_raw))
|
|
269
|
+
return result, current_offset
|
|
270
|
+
elif is_nested:
|
|
271
|
+
from lifx_emulator.protocol import protocol_types
|
|
272
|
+
|
|
273
|
+
struct_class = getattr(protocol_types, base_type)
|
|
274
|
+
result = []
|
|
275
|
+
current_offset = offset
|
|
276
|
+
for _ in range(array_count):
|
|
277
|
+
if issubclass(struct_class, cls):
|
|
278
|
+
item, current_offset = struct_class._unpack_internal(
|
|
279
|
+
data, current_offset
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
item_result = struct_class.unpack(data, current_offset)
|
|
283
|
+
item, current_offset = item_result # type: ignore[misc]
|
|
284
|
+
result.append(item)
|
|
285
|
+
return result, current_offset
|
|
286
|
+
elif base_type in ("uint8", "byte"):
|
|
287
|
+
if field_name.lower().endswith("label"):
|
|
288
|
+
return serializer.unpack_string(data, size_bytes, offset)
|
|
289
|
+
return serializer.unpack_bytes(data, size_bytes, offset)
|
|
290
|
+
else:
|
|
291
|
+
return serializer.unpack_array(data, base_type, array_count, offset)
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def _unpack_single_field(
|
|
295
|
+
cls,
|
|
296
|
+
data: bytes,
|
|
297
|
+
base_type: str,
|
|
298
|
+
offset: int,
|
|
299
|
+
is_nested: bool,
|
|
300
|
+
enum_types: dict,
|
|
216
301
|
) -> tuple[Any, int]:
|
|
217
|
-
"""Unpack a
|
|
302
|
+
"""Unpack a non-array field value."""
|
|
218
303
|
from lifx_emulator.protocol import serializer
|
|
304
|
+
|
|
305
|
+
is_enum = is_nested and base_type in enum_types
|
|
306
|
+
|
|
307
|
+
if is_enum:
|
|
308
|
+
enum_class = enum_types[base_type]
|
|
309
|
+
value_raw, new_offset = serializer.unpack_value(data, "uint8", offset)
|
|
310
|
+
return enum_class(value_raw), new_offset
|
|
311
|
+
elif is_nested:
|
|
312
|
+
from lifx_emulator.protocol import protocol_types
|
|
313
|
+
|
|
314
|
+
struct_class = getattr(protocol_types, base_type)
|
|
315
|
+
if issubclass(struct_class, cls):
|
|
316
|
+
return struct_class._unpack_internal(data, offset)
|
|
317
|
+
else:
|
|
318
|
+
return struct_class.unpack(data, offset)
|
|
319
|
+
else:
|
|
320
|
+
return serializer.unpack_value(data, base_type, offset)
|
|
321
|
+
|
|
322
|
+
@classmethod
|
|
323
|
+
def _unpack_field_value(
|
|
324
|
+
cls,
|
|
325
|
+
data: bytes,
|
|
326
|
+
field_type: str,
|
|
327
|
+
size_bytes: int,
|
|
328
|
+
offset: int,
|
|
329
|
+
field_name: str = "",
|
|
330
|
+
) -> tuple[Any, int]:
|
|
331
|
+
"""Unpack a single field value based on its type.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
data: Bytes to unpack from
|
|
335
|
+
field_type: Protocol field type string
|
|
336
|
+
size_bytes: Size in bytes
|
|
337
|
+
offset: Offset in bytes
|
|
338
|
+
field_name: Optional field name for semantic type detection
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Tuple of (value, new_offset)
|
|
342
|
+
"""
|
|
219
343
|
from lifx_emulator.protocol.protocol_types import (
|
|
220
344
|
DeviceService,
|
|
221
345
|
LightLastHevCycleResult,
|
|
@@ -223,12 +347,13 @@ class Packet:
|
|
|
223
347
|
MultiZoneApplicationRequest,
|
|
224
348
|
MultiZoneEffectType,
|
|
225
349
|
MultiZoneExtendedApplicationRequest,
|
|
350
|
+
TileEffectSkyPalette,
|
|
351
|
+
TileEffectSkyType,
|
|
352
|
+
TileEffectType,
|
|
226
353
|
)
|
|
227
354
|
|
|
228
|
-
# Parse field type
|
|
229
355
|
base_type, array_count, is_nested = cls._parse_field_type(field_type)
|
|
230
356
|
|
|
231
|
-
# Check if it's an enum (Button/Relay enums excluded)
|
|
232
357
|
enum_types = {
|
|
233
358
|
"DeviceService": DeviceService,
|
|
234
359
|
"LightLastHevCycleResult": LightLastHevCycleResult,
|
|
@@ -236,64 +361,26 @@ class Packet:
|
|
|
236
361
|
"MultiZoneApplicationRequest": MultiZoneApplicationRequest,
|
|
237
362
|
"MultiZoneEffectType": MultiZoneEffectType,
|
|
238
363
|
"MultiZoneExtendedApplicationRequest": MultiZoneExtendedApplicationRequest,
|
|
364
|
+
"TileEffectSkyPalette": TileEffectSkyPalette,
|
|
365
|
+
"TileEffectSkyType": TileEffectSkyType,
|
|
366
|
+
"TileEffectType": TileEffectType,
|
|
239
367
|
}
|
|
240
|
-
is_enum = is_nested and base_type in enum_types
|
|
241
368
|
|
|
242
|
-
# Handle different field types
|
|
243
369
|
if array_count:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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)
|
|
370
|
+
return cls._unpack_array_field(
|
|
371
|
+
data,
|
|
372
|
+
base_type,
|
|
373
|
+
array_count,
|
|
374
|
+
size_bytes,
|
|
375
|
+
offset,
|
|
376
|
+
field_name,
|
|
377
|
+
is_nested,
|
|
378
|
+
enum_types,
|
|
379
|
+
)
|
|
294
380
|
else:
|
|
295
|
-
|
|
296
|
-
|
|
381
|
+
return cls._unpack_single_field(
|
|
382
|
+
data, base_type, offset, is_nested, enum_types
|
|
383
|
+
)
|
|
297
384
|
|
|
298
385
|
@staticmethod
|
|
299
386
|
def _parse_field_type(field_type: str) -> tuple[str, int | None, bool]:
|
|
@@ -332,3 +419,28 @@ class Packet:
|
|
|
332
419
|
# Cache the result
|
|
333
420
|
_FIELD_TYPE_CACHE[field_type] = result
|
|
334
421
|
return result
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def _decode_labels_inplace(packet: object) -> None:
|
|
425
|
+
"""Decode label fields from bytes to string in-place.
|
|
426
|
+
|
|
427
|
+
Automatically finds and decodes any field named 'label' or ending with '_label'
|
|
428
|
+
for all State packets. This ensures human-readable labels in all contexts.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
packet: Packet instance to process (modified in-place)
|
|
432
|
+
"""
|
|
433
|
+
from dataclasses import fields, is_dataclass
|
|
434
|
+
|
|
435
|
+
if not is_dataclass(packet):
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
for field_info in fields(packet):
|
|
439
|
+
# Check if this looks like a label field
|
|
440
|
+
if field_info.name == "label" or field_info.name.endswith("_label"):
|
|
441
|
+
value = getattr(packet, field_info.name)
|
|
442
|
+
if isinstance(value, bytes):
|
|
443
|
+
# Decode: strip null terminator, decode UTF-8
|
|
444
|
+
decoded = value.rstrip(b"\x00").decode("utf-8")
|
|
445
|
+
# Use object.__setattr__ to bypass frozen dataclass if needed
|
|
446
|
+
object.__setattr__(packet, field_info.name, decoded)
|
|
@@ -300,7 +300,9 @@ def generate_enum_code(enums: dict[str, Any]) -> str:
|
|
|
300
300
|
|
|
301
301
|
|
|
302
302
|
def convert_type_to_python(
|
|
303
|
-
field_type: str,
|
|
303
|
+
field_type: str,
|
|
304
|
+
type_aliases: dict[str, str] | None = None,
|
|
305
|
+
field_name: str | None = None,
|
|
304
306
|
) -> str:
|
|
305
307
|
"""Convert a protocol field type to Python type annotation.
|
|
306
308
|
|
|
@@ -308,6 +310,8 @@ def convert_type_to_python(
|
|
|
308
310
|
field_type: Protocol field type string
|
|
309
311
|
type_aliases: Optional dict mapping type names to their aliases
|
|
310
312
|
(for collision resolution)
|
|
313
|
+
field_name: Optional field name for semantic type detection
|
|
314
|
+
(e.g., "Label" fields are strings, not bytes)
|
|
311
315
|
|
|
312
316
|
Returns:
|
|
313
317
|
Python type annotation string
|
|
@@ -323,7 +327,10 @@ def convert_type_to_python(
|
|
|
323
327
|
type_name = type_aliases.get(base_type, base_type)
|
|
324
328
|
return f"list[{type_name}]"
|
|
325
329
|
elif base_type in ("uint8", "byte"):
|
|
326
|
-
#
|
|
330
|
+
# Check if this is a string field (Label fields are UTF-8 strings)
|
|
331
|
+
if field_name and field_name.lower() == "label":
|
|
332
|
+
return "str"
|
|
333
|
+
# Regular byte arrays
|
|
327
334
|
return "bytes"
|
|
328
335
|
else:
|
|
329
336
|
return "list[int]"
|
|
@@ -664,7 +671,9 @@ def generate_field_code(
|
|
|
664
671
|
protocol_name = field_item["name"]
|
|
665
672
|
attr_type = field_item["type"]
|
|
666
673
|
python_name = to_snake_case(protocol_name)
|
|
667
|
-
python_type = convert_type_to_python(
|
|
674
|
+
python_type = convert_type_to_python(
|
|
675
|
+
attr_type, field_name=protocol_name
|
|
676
|
+
)
|
|
668
677
|
|
|
669
678
|
code.append(f" {python_name}: {python_type}")
|
|
670
679
|
field_map[python_name] = protocol_name
|
|
@@ -673,7 +682,9 @@ def generate_field_code(
|
|
|
673
682
|
# Convert to new format for pack/unpack generation
|
|
674
683
|
for protocol_name, attr_type in field_def.items():
|
|
675
684
|
python_name = to_snake_case(protocol_name)
|
|
676
|
-
python_type = convert_type_to_python(
|
|
685
|
+
python_type = convert_type_to_python(
|
|
686
|
+
attr_type, field_name=protocol_name
|
|
687
|
+
)
|
|
677
688
|
code.append(f" {python_name}: {python_type}")
|
|
678
689
|
field_map[python_name] = protocol_name
|
|
679
690
|
# Build fields_data for old format
|
|
@@ -832,7 +843,9 @@ def generate_nested_packet_code(
|
|
|
832
843
|
protocol_name = field_item["name"]
|
|
833
844
|
field_type = field_item["type"]
|
|
834
845
|
python_name = to_snake_case(protocol_name)
|
|
835
|
-
python_type = convert_type_to_python(
|
|
846
|
+
python_type = convert_type_to_python(
|
|
847
|
+
field_type, type_aliases, field_name=protocol_name
|
|
848
|
+
)
|
|
836
849
|
code.append(f" {python_name}: {python_type}")
|
|
837
850
|
has_fields = True
|
|
838
851
|
|
|
@@ -234,7 +234,7 @@ class Device(Packet):
|
|
|
234
234
|
_requires_response: ClassVar[bool] = False
|
|
235
235
|
|
|
236
236
|
group: bytes
|
|
237
|
-
label:
|
|
237
|
+
label: str
|
|
238
238
|
updated_at: int
|
|
239
239
|
|
|
240
240
|
@dataclass
|
|
@@ -251,7 +251,7 @@ class Device(Packet):
|
|
|
251
251
|
_requires_ack: ClassVar[bool] = True
|
|
252
252
|
_requires_response: ClassVar[bool] = False
|
|
253
253
|
|
|
254
|
-
label:
|
|
254
|
+
label: str
|
|
255
255
|
|
|
256
256
|
@dataclass
|
|
257
257
|
class SetLocation(Packet):
|
|
@@ -270,7 +270,7 @@ class Device(Packet):
|
|
|
270
270
|
_requires_response: ClassVar[bool] = False
|
|
271
271
|
|
|
272
272
|
location: bytes
|
|
273
|
-
label:
|
|
273
|
+
label: str
|
|
274
274
|
updated_at: int
|
|
275
275
|
|
|
276
276
|
@dataclass
|
|
@@ -320,7 +320,7 @@ class Device(Packet):
|
|
|
320
320
|
_requires_response: ClassVar[bool] = False
|
|
321
321
|
|
|
322
322
|
group: bytes
|
|
323
|
-
label:
|
|
323
|
+
label: str
|
|
324
324
|
updated_at: int
|
|
325
325
|
|
|
326
326
|
@dataclass
|
|
@@ -378,7 +378,7 @@ class Device(Packet):
|
|
|
378
378
|
_requires_ack: ClassVar[bool] = False
|
|
379
379
|
_requires_response: ClassVar[bool] = False
|
|
380
380
|
|
|
381
|
-
label:
|
|
381
|
+
label: str
|
|
382
382
|
|
|
383
383
|
@dataclass
|
|
384
384
|
class StateLocation(Packet):
|
|
@@ -397,7 +397,7 @@ class Device(Packet):
|
|
|
397
397
|
_requires_response: ClassVar[bool] = False
|
|
398
398
|
|
|
399
399
|
location: bytes
|
|
400
|
-
label:
|
|
400
|
+
label: str
|
|
401
401
|
updated_at: int
|
|
402
402
|
|
|
403
403
|
@dataclass
|
|
@@ -768,7 +768,7 @@ class Light(Packet):
|
|
|
768
768
|
|
|
769
769
|
color: LightHsbk
|
|
770
770
|
power: int
|
|
771
|
-
label:
|
|
771
|
+
label: str
|
|
772
772
|
|
|
773
773
|
@dataclass
|
|
774
774
|
class StateHevCycle(Packet):
|