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.
Files changed (58) hide show
  1. lifx_emulator/__init__.py +1 -1
  2. lifx_emulator/__main__.py +26 -51
  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 +346 -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 +31 -11
  32. lifx_emulator/products/generator.py +389 -170
  33. lifx_emulator/products/registry.py +52 -40
  34. lifx_emulator/products/specs.py +12 -13
  35. lifx_emulator/protocol/base.py +175 -63
  36. lifx_emulator/protocol/generator.py +18 -5
  37. lifx_emulator/protocol/packets.py +7 -7
  38. lifx_emulator/protocol/protocol_types.py +35 -62
  39. lifx_emulator/repositories/__init__.py +22 -0
  40. lifx_emulator/repositories/device_repository.py +155 -0
  41. lifx_emulator/repositories/storage_backend.py +107 -0
  42. lifx_emulator/scenarios/__init__.py +22 -0
  43. lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
  44. lifx_emulator/scenarios/models.py +112 -0
  45. lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
  46. lifx_emulator/server.py +42 -66
  47. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/METADATA +1 -1
  48. lifx_emulator-2.1.0.dist-info/RECORD +62 -0
  49. lifx_emulator/device.py +0 -750
  50. lifx_emulator/device_states.py +0 -114
  51. lifx_emulator/factories.py +0 -380
  52. lifx_emulator/storage_protocol.py +0 -100
  53. lifx_emulator-1.0.2.dist-info/RECORD +0 -40
  54. /lifx_emulator/{observers.py → devices/observers.py} +0 -0
  55. /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
  56. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/WHEEL +0 -0
  57. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/entry_points.txt +0 -0
  58. {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"):
@@ -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
 
@@ -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
- # Byte array
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 _unpack_field_value(
215
- cls, data: bytes, field_type: str, size_bytes: int, offset: int
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 single field value based on its type."""
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
- 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)
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
- # Primitive type
296
- return serializer.unpack_value(data, base_type, offset)
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, 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
 
@@ -234,7 +234,7 @@ class Device(Packet):
234
234
  _requires_response: ClassVar[bool] = False
235
235
 
236
236
  group: bytes
237
- label: bytes
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: bytes
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: bytes
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: bytes
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: bytes
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: bytes
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: bytes
771
+ label: str
772
772
 
773
773
  @dataclass
774
774
  class StateHevCycle(Packet):