lifx-emulator 1.0.1__py3-none-any.whl → 2.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 +1 -1
- lifx_emulator/__main__.py +26 -49
- 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 +333 -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 +1 -1
- lifx_emulator/products/generator.py +406 -175
- lifx_emulator/products/registry.py +115 -65
- lifx_emulator/products/specs.py +12 -13
- lifx_emulator/protocol/base.py +115 -61
- lifx_emulator/protocol/generator.py +18 -5
- lifx_emulator/protocol/packets.py +7 -7
- 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 +38 -64
- {lifx_emulator-1.0.1.dist-info → lifx_emulator-2.0.0.dist-info}/METADATA +1 -1
- lifx_emulator-2.0.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.1.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.1.dist-info → lifx_emulator-2.0.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-1.0.1.dist-info → lifx_emulator-2.0.0.dist-info}/entry_points.txt +0 -0
- {lifx_emulator-1.0.1.dist-info → lifx_emulator-2.0.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,27 +681,13 @@ 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",
|
|
679
687
|
vendor=1,
|
|
680
|
-
capabilities=ProductCapability.COLOR
|
|
688
|
+
capabilities=ProductCapability.COLOR
|
|
689
|
+
| ProductCapability.MULTIZONE
|
|
690
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
681
691
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
682
692
|
min_ext_mz_firmware=None,
|
|
683
693
|
),
|
|
@@ -685,7 +695,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
685
695
|
pid=118,
|
|
686
696
|
name="LIFX Z Intl",
|
|
687
697
|
vendor=1,
|
|
688
|
-
capabilities=ProductCapability.COLOR
|
|
698
|
+
capabilities=ProductCapability.COLOR
|
|
699
|
+
| ProductCapability.MULTIZONE
|
|
700
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
689
701
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
690
702
|
min_ext_mz_firmware=None,
|
|
691
703
|
),
|
|
@@ -693,7 +705,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
693
705
|
pid=119,
|
|
694
706
|
name="LIFX Beam US",
|
|
695
707
|
vendor=1,
|
|
696
|
-
capabilities=ProductCapability.COLOR
|
|
708
|
+
capabilities=ProductCapability.COLOR
|
|
709
|
+
| ProductCapability.MULTIZONE
|
|
710
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
697
711
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
698
712
|
min_ext_mz_firmware=None,
|
|
699
713
|
),
|
|
@@ -701,7 +715,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
701
715
|
pid=120,
|
|
702
716
|
name="LIFX Beam Intl",
|
|
703
717
|
vendor=1,
|
|
704
|
-
capabilities=ProductCapability.COLOR
|
|
718
|
+
capabilities=ProductCapability.COLOR
|
|
719
|
+
| ProductCapability.MULTIZONE
|
|
720
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
705
721
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
706
722
|
min_ext_mz_firmware=None,
|
|
707
723
|
),
|
|
@@ -853,7 +869,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
853
869
|
pid=141,
|
|
854
870
|
name="LIFX Neon US",
|
|
855
871
|
vendor=1,
|
|
856
|
-
capabilities=ProductCapability.COLOR
|
|
872
|
+
capabilities=ProductCapability.COLOR
|
|
873
|
+
| ProductCapability.MULTIZONE
|
|
874
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
857
875
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
858
876
|
min_ext_mz_firmware=None,
|
|
859
877
|
),
|
|
@@ -861,7 +879,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
861
879
|
pid=142,
|
|
862
880
|
name="LIFX Neon Intl",
|
|
863
881
|
vendor=1,
|
|
864
|
-
capabilities=ProductCapability.COLOR
|
|
882
|
+
capabilities=ProductCapability.COLOR
|
|
883
|
+
| ProductCapability.MULTIZONE
|
|
884
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
865
885
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
866
886
|
min_ext_mz_firmware=None,
|
|
867
887
|
),
|
|
@@ -869,7 +889,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
869
889
|
pid=143,
|
|
870
890
|
name="LIFX String US",
|
|
871
891
|
vendor=1,
|
|
872
|
-
capabilities=ProductCapability.COLOR
|
|
892
|
+
capabilities=ProductCapability.COLOR
|
|
893
|
+
| ProductCapability.MULTIZONE
|
|
894
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
873
895
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
874
896
|
min_ext_mz_firmware=None,
|
|
875
897
|
),
|
|
@@ -877,7 +899,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
877
899
|
pid=144,
|
|
878
900
|
name="LIFX String Intl",
|
|
879
901
|
vendor=1,
|
|
880
|
-
capabilities=ProductCapability.COLOR
|
|
902
|
+
capabilities=ProductCapability.COLOR
|
|
903
|
+
| ProductCapability.MULTIZONE
|
|
904
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
881
905
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
882
906
|
min_ext_mz_firmware=None,
|
|
883
907
|
),
|
|
@@ -885,7 +909,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
885
909
|
pid=161,
|
|
886
910
|
name="LIFX Outdoor Neon US",
|
|
887
911
|
vendor=1,
|
|
888
|
-
capabilities=ProductCapability.COLOR
|
|
912
|
+
capabilities=ProductCapability.COLOR
|
|
913
|
+
| ProductCapability.MULTIZONE
|
|
914
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
889
915
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
890
916
|
min_ext_mz_firmware=None,
|
|
891
917
|
),
|
|
@@ -893,7 +919,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
893
919
|
pid=162,
|
|
894
920
|
name="LIFX Outdoor Neon Intl",
|
|
895
921
|
vendor=1,
|
|
896
|
-
capabilities=ProductCapability.COLOR
|
|
922
|
+
capabilities=ProductCapability.COLOR
|
|
923
|
+
| ProductCapability.MULTIZONE
|
|
924
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
897
925
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
898
926
|
min_ext_mz_firmware=None,
|
|
899
927
|
),
|
|
@@ -1101,7 +1129,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
1101
1129
|
pid=203,
|
|
1102
1130
|
name="LIFX String US",
|
|
1103
1131
|
vendor=1,
|
|
1104
|
-
capabilities=ProductCapability.COLOR
|
|
1132
|
+
capabilities=ProductCapability.COLOR
|
|
1133
|
+
| ProductCapability.MULTIZONE
|
|
1134
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
1105
1135
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
1106
1136
|
min_ext_mz_firmware=None,
|
|
1107
1137
|
),
|
|
@@ -1109,7 +1139,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
1109
1139
|
pid=204,
|
|
1110
1140
|
name="LIFX String Intl",
|
|
1111
1141
|
vendor=1,
|
|
1112
|
-
capabilities=ProductCapability.COLOR
|
|
1142
|
+
capabilities=ProductCapability.COLOR
|
|
1143
|
+
| ProductCapability.MULTIZONE
|
|
1144
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
1113
1145
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
1114
1146
|
min_ext_mz_firmware=None,
|
|
1115
1147
|
),
|
|
@@ -1117,7 +1149,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
1117
1149
|
pid=205,
|
|
1118
1150
|
name="LIFX Indoor Neon US",
|
|
1119
1151
|
vendor=1,
|
|
1120
|
-
capabilities=ProductCapability.COLOR
|
|
1152
|
+
capabilities=ProductCapability.COLOR
|
|
1153
|
+
| ProductCapability.MULTIZONE
|
|
1154
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
1121
1155
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
1122
1156
|
min_ext_mz_firmware=None,
|
|
1123
1157
|
),
|
|
@@ -1125,7 +1159,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
1125
1159
|
pid=206,
|
|
1126
1160
|
name="LIFX Indoor Neon Intl",
|
|
1127
1161
|
vendor=1,
|
|
1128
|
-
capabilities=ProductCapability.COLOR
|
|
1162
|
+
capabilities=ProductCapability.COLOR
|
|
1163
|
+
| ProductCapability.MULTIZONE
|
|
1164
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
1129
1165
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
1130
1166
|
min_ext_mz_firmware=None,
|
|
1131
1167
|
),
|
|
@@ -1133,7 +1169,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
1133
1169
|
pid=213,
|
|
1134
1170
|
name="LIFX Permanent Outdoor US",
|
|
1135
1171
|
vendor=1,
|
|
1136
|
-
capabilities=ProductCapability.COLOR
|
|
1172
|
+
capabilities=ProductCapability.COLOR
|
|
1173
|
+
| ProductCapability.MULTIZONE
|
|
1174
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
1137
1175
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
1138
1176
|
min_ext_mz_firmware=None,
|
|
1139
1177
|
),
|
|
@@ -1141,7 +1179,9 @@ PRODUCTS: dict[int, ProductInfo] = {
|
|
|
1141
1179
|
pid=214,
|
|
1142
1180
|
name="LIFX Permanent Outdoor Intl",
|
|
1143
1181
|
vendor=1,
|
|
1144
|
-
capabilities=ProductCapability.COLOR
|
|
1182
|
+
capabilities=ProductCapability.COLOR
|
|
1183
|
+
| ProductCapability.MULTIZONE
|
|
1184
|
+
| ProductCapability.EXTENDED_MULTIZONE,
|
|
1145
1185
|
temperature_range=TemperatureRange(min=1500, max=9000),
|
|
1146
1186
|
min_ext_mz_firmware=None,
|
|
1147
1187
|
),
|
|
@@ -1281,6 +1321,10 @@ class ProductRegistry:
|
|
|
1281
1321
|
prod_features = product.get("features", {})
|
|
1282
1322
|
features: dict[str, Any] = {**default_features, **prod_features}
|
|
1283
1323
|
|
|
1324
|
+
# Skip switch products (devices with relays) - these are not lights
|
|
1325
|
+
if features.get("relays"):
|
|
1326
|
+
continue
|
|
1327
|
+
|
|
1284
1328
|
# Build capabilities bitfield
|
|
1285
1329
|
capabilities = 0
|
|
1286
1330
|
if features.get("color"):
|
|
@@ -1300,16 +1344,22 @@ class ProductRegistry:
|
|
|
1300
1344
|
if features.get("hev"):
|
|
1301
1345
|
capabilities |= ProductCapability.HEV
|
|
1302
1346
|
|
|
1303
|
-
# Check for extended multizone
|
|
1347
|
+
# Check for extended multizone capability
|
|
1304
1348
|
min_ext_mz_firmware = None
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1349
|
+
|
|
1350
|
+
# First check if it's a native feature (no firmware requirement)
|
|
1351
|
+
if features.get("extended_multizone"):
|
|
1352
|
+
capabilities |= ProductCapability.EXTENDED_MULTIZONE
|
|
1353
|
+
else:
|
|
1354
|
+
# Check if it's available as an upgrade (requires minimum firmware)
|
|
1355
|
+
for upgrade in product.get("upgrades", []):
|
|
1356
|
+
if upgrade.get("features", {}).get("extended_multizone"):
|
|
1357
|
+
capabilities |= ProductCapability.EXTENDED_MULTIZONE
|
|
1358
|
+
# Parse firmware version (major.minor format)
|
|
1359
|
+
major = upgrade.get("major", 0)
|
|
1360
|
+
minor = upgrade.get("minor", 0)
|
|
1361
|
+
min_ext_mz_firmware = (major << 16) | minor
|
|
1362
|
+
break
|
|
1313
1363
|
|
|
1314
1364
|
# Parse temperature range
|
|
1315
1365
|
temp_range = None
|
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
|
@@ -137,7 +137,7 @@ class Packet:
|
|
|
137
137
|
|
|
138
138
|
# Unpack field value
|
|
139
139
|
value, current_offset = cls._unpack_field_value(
|
|
140
|
-
data, field_type, size_bytes, current_offset
|
|
140
|
+
data, field_type, size_bytes, current_offset, field_name
|
|
141
141
|
)
|
|
142
142
|
field_values[field_name] = value
|
|
143
143
|
|
|
@@ -195,7 +195,10 @@ class Packet:
|
|
|
195
195
|
result += item.pack()
|
|
196
196
|
return result
|
|
197
197
|
elif base_type in ("uint8", "byte"):
|
|
198
|
-
#
|
|
198
|
+
# Check if value is a string (Label fields)
|
|
199
|
+
if isinstance(value, str):
|
|
200
|
+
return serializer.pack_string(value, size_bytes)
|
|
201
|
+
# Regular byte array
|
|
199
202
|
return serializer.pack_bytes(value, size_bytes)
|
|
200
203
|
else:
|
|
201
204
|
# Array of primitives
|
|
@@ -211,11 +214,105 @@ class Packet:
|
|
|
211
214
|
return serializer.pack_value(value, base_type)
|
|
212
215
|
|
|
213
216
|
@classmethod
|
|
214
|
-
def
|
|
215
|
-
cls,
|
|
217
|
+
def _unpack_array_field(
|
|
218
|
+
cls,
|
|
219
|
+
data: bytes,
|
|
220
|
+
base_type: str,
|
|
221
|
+
array_count: int,
|
|
222
|
+
size_bytes: int,
|
|
223
|
+
offset: int,
|
|
224
|
+
field_name: str,
|
|
225
|
+
is_nested: bool,
|
|
226
|
+
enum_types: dict,
|
|
227
|
+
) -> tuple[Any, int]:
|
|
228
|
+
"""Unpack an array field value."""
|
|
229
|
+
from lifx_emulator.protocol import serializer
|
|
230
|
+
|
|
231
|
+
is_enum = is_nested and base_type in enum_types
|
|
232
|
+
|
|
233
|
+
if is_enum:
|
|
234
|
+
result = []
|
|
235
|
+
current_offset = offset
|
|
236
|
+
enum_class = enum_types[base_type]
|
|
237
|
+
for _ in range(array_count):
|
|
238
|
+
item_raw, current_offset = serializer.unpack_value(
|
|
239
|
+
data, "uint8", current_offset
|
|
240
|
+
)
|
|
241
|
+
result.append(enum_class(item_raw))
|
|
242
|
+
return result, current_offset
|
|
243
|
+
elif is_nested:
|
|
244
|
+
from lifx_emulator.protocol import protocol_types
|
|
245
|
+
|
|
246
|
+
struct_class = getattr(protocol_types, base_type)
|
|
247
|
+
result = []
|
|
248
|
+
current_offset = offset
|
|
249
|
+
for _ in range(array_count):
|
|
250
|
+
if issubclass(struct_class, cls):
|
|
251
|
+
item, current_offset = struct_class._unpack_internal(
|
|
252
|
+
data, current_offset
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
item_result = struct_class.unpack(data, current_offset)
|
|
256
|
+
item, current_offset = item_result # type: ignore[misc]
|
|
257
|
+
result.append(item)
|
|
258
|
+
return result, current_offset
|
|
259
|
+
elif base_type in ("uint8", "byte"):
|
|
260
|
+
if field_name.lower().endswith("label"):
|
|
261
|
+
return serializer.unpack_string(data, size_bytes, offset)
|
|
262
|
+
return serializer.unpack_bytes(data, size_bytes, offset)
|
|
263
|
+
else:
|
|
264
|
+
return serializer.unpack_array(data, base_type, array_count, offset)
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def _unpack_single_field(
|
|
268
|
+
cls,
|
|
269
|
+
data: bytes,
|
|
270
|
+
base_type: str,
|
|
271
|
+
offset: int,
|
|
272
|
+
is_nested: bool,
|
|
273
|
+
enum_types: dict,
|
|
216
274
|
) -> tuple[Any, int]:
|
|
217
|
-
"""Unpack a
|
|
275
|
+
"""Unpack a non-array field value."""
|
|
218
276
|
from lifx_emulator.protocol import serializer
|
|
277
|
+
|
|
278
|
+
is_enum = is_nested and base_type in enum_types
|
|
279
|
+
|
|
280
|
+
if is_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
|
+
from lifx_emulator.protocol import protocol_types
|
|
286
|
+
|
|
287
|
+
struct_class = getattr(protocol_types, base_type)
|
|
288
|
+
if issubclass(struct_class, cls):
|
|
289
|
+
return struct_class._unpack_internal(data, offset)
|
|
290
|
+
else:
|
|
291
|
+
return struct_class.unpack(data, offset)
|
|
292
|
+
else:
|
|
293
|
+
return serializer.unpack_value(data, base_type, offset)
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def _unpack_field_value(
|
|
297
|
+
cls,
|
|
298
|
+
data: bytes,
|
|
299
|
+
field_type: str,
|
|
300
|
+
size_bytes: int,
|
|
301
|
+
offset: int,
|
|
302
|
+
field_name: str = "",
|
|
303
|
+
) -> tuple[Any, int]:
|
|
304
|
+
"""Unpack a single field value based on its type.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
data: Bytes to unpack from
|
|
308
|
+
field_type: Protocol field type string
|
|
309
|
+
size_bytes: Size in bytes
|
|
310
|
+
offset: Offset in bytes
|
|
311
|
+
field_name: Optional field name for semantic type detection
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Tuple of (value, new_offset)
|
|
315
|
+
"""
|
|
219
316
|
from lifx_emulator.protocol.protocol_types import (
|
|
220
317
|
DeviceService,
|
|
221
318
|
LightLastHevCycleResult,
|
|
@@ -225,10 +322,8 @@ class Packet:
|
|
|
225
322
|
MultiZoneExtendedApplicationRequest,
|
|
226
323
|
)
|
|
227
324
|
|
|
228
|
-
# Parse field type
|
|
229
325
|
base_type, array_count, is_nested = cls._parse_field_type(field_type)
|
|
230
326
|
|
|
231
|
-
# Check if it's an enum (Button/Relay enums excluded)
|
|
232
327
|
enum_types = {
|
|
233
328
|
"DeviceService": DeviceService,
|
|
234
329
|
"LightLastHevCycleResult": LightLastHevCycleResult,
|
|
@@ -237,63 +332,22 @@ class Packet:
|
|
|
237
332
|
"MultiZoneEffectType": MultiZoneEffectType,
|
|
238
333
|
"MultiZoneExtendedApplicationRequest": MultiZoneExtendedApplicationRequest,
|
|
239
334
|
}
|
|
240
|
-
is_enum = is_nested and base_type in enum_types
|
|
241
335
|
|
|
242
|
-
# Handle different field types
|
|
243
336
|
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)
|
|
337
|
+
return cls._unpack_array_field(
|
|
338
|
+
data,
|
|
339
|
+
base_type,
|
|
340
|
+
array_count,
|
|
341
|
+
size_bytes,
|
|
342
|
+
offset,
|
|
343
|
+
field_name,
|
|
344
|
+
is_nested,
|
|
345
|
+
enum_types,
|
|
346
|
+
)
|
|
294
347
|
else:
|
|
295
|
-
|
|
296
|
-
|
|
348
|
+
return cls._unpack_single_field(
|
|
349
|
+
data, base_type, offset, is_nested, enum_types
|
|
350
|
+
)
|
|
297
351
|
|
|
298
352
|
@staticmethod
|
|
299
353
|
def _parse_field_type(field_type: str) -> tuple[str, int | None, bool]:
|
|
@@ -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
|
|