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.
Files changed (57) hide show
  1. lifx_emulator/__init__.py +1 -1
  2. lifx_emulator/__main__.py +26 -49
  3. lifx_emulator/api/__init__.py +18 -0
  4. lifx_emulator/api/app.py +154 -0
  5. lifx_emulator/api/mappers/__init__.py +5 -0
  6. lifx_emulator/api/mappers/device_mapper.py +114 -0
  7. lifx_emulator/api/models.py +133 -0
  8. lifx_emulator/api/routers/__init__.py +11 -0
  9. lifx_emulator/api/routers/devices.py +130 -0
  10. lifx_emulator/api/routers/monitoring.py +52 -0
  11. lifx_emulator/api/routers/scenarios.py +247 -0
  12. lifx_emulator/api/services/__init__.py +8 -0
  13. lifx_emulator/api/services/device_service.py +198 -0
  14. lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
  15. lifx_emulator/devices/__init__.py +37 -0
  16. lifx_emulator/devices/device.py +333 -0
  17. lifx_emulator/devices/manager.py +256 -0
  18. lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
  19. lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
  20. lifx_emulator/devices/states.py +333 -0
  21. lifx_emulator/factories/__init__.py +37 -0
  22. lifx_emulator/factories/builder.py +371 -0
  23. lifx_emulator/factories/default_config.py +158 -0
  24. lifx_emulator/factories/factory.py +221 -0
  25. lifx_emulator/factories/firmware_config.py +59 -0
  26. lifx_emulator/factories/serial_generator.py +82 -0
  27. lifx_emulator/handlers/base.py +1 -1
  28. lifx_emulator/handlers/device_handlers.py +10 -28
  29. lifx_emulator/handlers/light_handlers.py +5 -9
  30. lifx_emulator/handlers/multizone_handlers.py +1 -1
  31. lifx_emulator/handlers/tile_handlers.py +1 -1
  32. lifx_emulator/products/generator.py +406 -175
  33. lifx_emulator/products/registry.py +115 -65
  34. lifx_emulator/products/specs.py +12 -13
  35. lifx_emulator/protocol/base.py +115 -61
  36. lifx_emulator/protocol/generator.py +18 -5
  37. lifx_emulator/protocol/packets.py +7 -7
  38. lifx_emulator/repositories/__init__.py +22 -0
  39. lifx_emulator/repositories/device_repository.py +155 -0
  40. lifx_emulator/repositories/storage_backend.py +107 -0
  41. lifx_emulator/scenarios/__init__.py +22 -0
  42. lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
  43. lifx_emulator/scenarios/models.py +112 -0
  44. lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
  45. lifx_emulator/server.py +38 -64
  46. {lifx_emulator-1.0.1.dist-info → lifx_emulator-2.0.0.dist-info}/METADATA +1 -1
  47. lifx_emulator-2.0.0.dist-info/RECORD +62 -0
  48. lifx_emulator/device.py +0 -750
  49. lifx_emulator/device_states.py +0 -114
  50. lifx_emulator/factories.py +0 -380
  51. lifx_emulator/storage_protocol.py +0 -100
  52. lifx_emulator-1.0.1.dist-info/RECORD +0 -40
  53. /lifx_emulator/{observers.py → devices/observers.py} +0 -0
  54. /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
  55. {lifx_emulator-1.0.1.dist-info → lifx_emulator-2.0.0.dist-info}/WHEEL +0 -0
  56. {lifx_emulator-1.0.1.dist-info → lifx_emulator-2.0.0.dist-info}/entry_points.txt +0 -0
  57. {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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 | ProductCapability.MULTIZONE,
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 in upgrades
1347
+ # Check for extended multizone capability
1304
1348
  min_ext_mz_firmware = None
1305
- for upgrade in product.get("upgrades", []):
1306
- if upgrade.get("features", {}).get("extended_multizone"):
1307
- capabilities |= ProductCapability.EXTENDED_MULTIZONE
1308
- # Parse firmware version (major.minor format)
1309
- major = upgrade.get("major", 0)
1310
- minor = upgrade.get("minor", 0)
1311
- min_ext_mz_firmware = (major << 16) | minor
1312
- break
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
@@ -83,19 +83,18 @@ class SpecsRegistry:
83
83
 
84
84
  # Parse product specs
85
85
  for pid, specs_data in data["products"].items():
86
- if isinstance(specs_data, dict):
87
- self._specs[int(pid)] = ProductSpecs(
88
- product_id=int(pid),
89
- default_zone_count=specs_data.get("default_zone_count"),
90
- min_zone_count=specs_data.get("min_zone_count"),
91
- max_zone_count=specs_data.get("max_zone_count"),
92
- default_tile_count=specs_data.get("default_tile_count"),
93
- min_tile_count=specs_data.get("min_tile_count"),
94
- max_tile_count=specs_data.get("max_tile_count"),
95
- tile_width=specs_data.get("tile_width"),
96
- tile_height=specs_data.get("tile_height"),
97
- notes=specs_data.get("notes"),
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
 
@@ -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
- # Byte array
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 _unpack_field_value(
215
- cls, data: bytes, field_type: str, size_bytes: int, offset: int
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 single field value based on its type."""
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
- 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)
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
- # Primitive type
296
- return serializer.unpack_value(data, base_type, offset)
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, type_aliases: dict[str, str] | None = None
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
- # Special case: byte arrays
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(attr_type)
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(attr_type)
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(field_type, type_aliases)
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