lifx-emulator 2.3.1__py3-none-any.whl → 3.0.1__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 (68) hide show
  1. lifx_emulator-3.0.1.dist-info/METADATA +102 -0
  2. lifx_emulator-3.0.1.dist-info/RECORD +18 -0
  3. lifx_emulator-3.0.1.dist-info/entry_points.txt +2 -0
  4. lifx_emulator_app/__init__.py +10 -0
  5. {lifx_emulator → lifx_emulator_app}/__main__.py +13 -5
  6. {lifx_emulator → lifx_emulator_app}/api/__init__.py +1 -1
  7. {lifx_emulator → lifx_emulator_app}/api/app.py +3 -3
  8. {lifx_emulator → lifx_emulator_app}/api/mappers/__init__.py +1 -1
  9. {lifx_emulator → lifx_emulator_app}/api/mappers/device_mapper.py +1 -1
  10. {lifx_emulator → lifx_emulator_app}/api/models.py +1 -2
  11. lifx_emulator_app/api/routers/__init__.py +11 -0
  12. {lifx_emulator → lifx_emulator_app}/api/routers/devices.py +2 -2
  13. {lifx_emulator → lifx_emulator_app}/api/routers/monitoring.py +1 -1
  14. {lifx_emulator → lifx_emulator_app}/api/routers/scenarios.py +1 -1
  15. lifx_emulator_app/api/services/__init__.py +8 -0
  16. {lifx_emulator → lifx_emulator_app}/api/services/device_service.py +3 -2
  17. lifx_emulator/__init__.py +0 -31
  18. lifx_emulator/api/routers/__init__.py +0 -11
  19. lifx_emulator/api/services/__init__.py +0 -8
  20. lifx_emulator/constants.py +0 -33
  21. lifx_emulator/devices/__init__.py +0 -37
  22. lifx_emulator/devices/device.py +0 -339
  23. lifx_emulator/devices/manager.py +0 -256
  24. lifx_emulator/devices/observers.py +0 -139
  25. lifx_emulator/devices/persistence.py +0 -308
  26. lifx_emulator/devices/state_restorer.py +0 -259
  27. lifx_emulator/devices/state_serializer.py +0 -157
  28. lifx_emulator/devices/states.py +0 -377
  29. lifx_emulator/factories/__init__.py +0 -37
  30. lifx_emulator/factories/builder.py +0 -373
  31. lifx_emulator/factories/default_config.py +0 -158
  32. lifx_emulator/factories/factory.py +0 -221
  33. lifx_emulator/factories/firmware_config.py +0 -77
  34. lifx_emulator/factories/serial_generator.py +0 -82
  35. lifx_emulator/handlers/__init__.py +0 -39
  36. lifx_emulator/handlers/base.py +0 -49
  37. lifx_emulator/handlers/device_handlers.py +0 -322
  38. lifx_emulator/handlers/light_handlers.py +0 -503
  39. lifx_emulator/handlers/multizone_handlers.py +0 -249
  40. lifx_emulator/handlers/registry.py +0 -110
  41. lifx_emulator/handlers/tile_handlers.py +0 -488
  42. lifx_emulator/products/__init__.py +0 -28
  43. lifx_emulator/products/generator.py +0 -1037
  44. lifx_emulator/products/registry.py +0 -1496
  45. lifx_emulator/products/specs.py +0 -284
  46. lifx_emulator/products/specs.yml +0 -352
  47. lifx_emulator/protocol/__init__.py +0 -1
  48. lifx_emulator/protocol/base.py +0 -446
  49. lifx_emulator/protocol/const.py +0 -8
  50. lifx_emulator/protocol/generator.py +0 -1384
  51. lifx_emulator/protocol/header.py +0 -159
  52. lifx_emulator/protocol/packets.py +0 -1351
  53. lifx_emulator/protocol/protocol_types.py +0 -817
  54. lifx_emulator/protocol/serializer.py +0 -379
  55. lifx_emulator/repositories/__init__.py +0 -22
  56. lifx_emulator/repositories/device_repository.py +0 -155
  57. lifx_emulator/repositories/storage_backend.py +0 -107
  58. lifx_emulator/scenarios/__init__.py +0 -22
  59. lifx_emulator/scenarios/manager.py +0 -322
  60. lifx_emulator/scenarios/models.py +0 -112
  61. lifx_emulator/scenarios/persistence.py +0 -241
  62. lifx_emulator/server.py +0 -464
  63. lifx_emulator-2.3.1.dist-info/METADATA +0 -107
  64. lifx_emulator-2.3.1.dist-info/RECORD +0 -62
  65. lifx_emulator-2.3.1.dist-info/entry_points.txt +0 -2
  66. lifx_emulator-2.3.1.dist-info/licenses/LICENSE +0 -35
  67. {lifx_emulator-2.3.1.dist-info → lifx_emulator-3.0.1.dist-info}/WHEEL +0 -0
  68. {lifx_emulator → lifx_emulator_app}/api/templates/dashboard.html +0 -0
@@ -1,1037 +0,0 @@
1
- """Code generator for LIFX product registry.
2
-
3
- Downloads the official products.json from the LIFX GitHub repository and
4
- generates optimized Python code with pre-built product definitions.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import json
10
- import sys
11
- from pathlib import Path
12
- from typing import Any
13
- from urllib.request import urlopen
14
-
15
- import yaml
16
-
17
- from lifx_emulator.constants import PRODUCTS_URL
18
-
19
-
20
- def _build_capabilities(features: dict[str, Any]) -> list[str]:
21
- """Build list of capability flags from product features.
22
-
23
- Args:
24
- features: Product features dictionary
25
-
26
- Returns:
27
- List of ProductCapability enum names
28
- """
29
- capabilities = []
30
- if features.get("color"):
31
- capabilities.append("ProductCapability.COLOR")
32
- if features.get("infrared"):
33
- capabilities.append("ProductCapability.INFRARED")
34
- if features.get("multizone"):
35
- capabilities.append("ProductCapability.MULTIZONE")
36
- if features.get("chain"):
37
- capabilities.append("ProductCapability.CHAIN")
38
- if features.get("matrix"):
39
- capabilities.append("ProductCapability.MATRIX")
40
- if features.get("relays"):
41
- capabilities.append("ProductCapability.RELAYS")
42
- if features.get("buttons"):
43
- capabilities.append("ProductCapability.BUTTONS")
44
- if features.get("hev"):
45
- capabilities.append("ProductCapability.HEV")
46
- return capabilities
47
-
48
-
49
- def _check_extended_multizone(
50
- product: dict[str, Any], features: dict[str, Any]
51
- ) -> tuple[bool, int | None]:
52
- """Check if product supports extended multizone and get minimum firmware.
53
-
54
- Args:
55
- product: Product dictionary with upgrades
56
- features: Product features dictionary
57
-
58
- Returns:
59
- Tuple of (has_extended_multizone, min_firmware_version)
60
- """
61
- # First check if it's a native feature (no firmware requirement)
62
- if features.get("extended_multizone"):
63
- return True, None
64
-
65
- # Check if it's available as an upgrade (requires minimum firmware)
66
- for upgrade in product.get("upgrades", []):
67
- if upgrade.get("features", {}).get("extended_multizone"):
68
- # Parse firmware version (major.minor format)
69
- major = upgrade.get("major", 0)
70
- minor = upgrade.get("minor", 0)
71
- min_ext_mz_firmware = (major << 16) | minor
72
- return True, min_ext_mz_firmware
73
-
74
- return False, None
75
-
76
-
77
- def _format_temperature_range(features: dict[str, Any]) -> str:
78
- """Format temperature range as Python code.
79
-
80
- Args:
81
- features: Product features dictionary
82
-
83
- Returns:
84
- Temperature range expression as string
85
- """
86
- if "temperature_range" not in features:
87
- return "None"
88
-
89
- temp_list = features["temperature_range"]
90
- if len(temp_list) >= 2:
91
- return f"TemperatureRange(min={temp_list[0]}, max={temp_list[1]})"
92
-
93
- return "None"
94
-
95
-
96
- def _generate_product_code(
97
- pid: int,
98
- name: str,
99
- vendor_id: int,
100
- capabilities_expr: str,
101
- temp_range_expr: str,
102
- min_ext_mz_firmware: int | None,
103
- ) -> list[str]:
104
- """Generate Python code lines for a single ProductInfo instance.
105
-
106
- Args:
107
- pid: Product ID
108
- name: Product name
109
- vendor_id: Vendor ID
110
- capabilities_expr: Capabilities bitfield expression
111
- temp_range_expr: Temperature range expression
112
- min_ext_mz_firmware: Minimum firmware for extended multizone
113
-
114
- Returns:
115
- List of code lines
116
- """
117
- min_ext_mz_firmware_expr = (
118
- str(min_ext_mz_firmware) if min_ext_mz_firmware is not None else "None"
119
- )
120
-
121
- return [
122
- f" {pid}: ProductInfo(",
123
- f" pid={pid},",
124
- f" name={repr(name)},",
125
- f" vendor={vendor_id},",
126
- f" capabilities={capabilities_expr},",
127
- f" temperature_range={temp_range_expr},",
128
- f" min_ext_mz_firmware={min_ext_mz_firmware_expr},",
129
- " ),",
130
- ]
131
-
132
-
133
- def download_products() -> dict[str, Any] | list[dict[str, Any]]:
134
- """Download and parse products.json from LIFX GitHub repository.
135
-
136
- Returns:
137
- Parsed products dictionary or list
138
-
139
- Raises:
140
- URLError: If download fails
141
- json.JSONDecodeError: If parsing fails
142
- """
143
- print(f"Downloading products.json from {PRODUCTS_URL}...")
144
- with urlopen(PRODUCTS_URL) as response: # nosec
145
- products_data = response.read()
146
-
147
- print("Parsing products specification...")
148
- products = json.loads(products_data)
149
- return products
150
-
151
-
152
- def generate_product_definitions(
153
- products_data: dict[str, Any] | list[dict[str, Any]],
154
- ) -> str:
155
- """Generate Python code for product definitions.
156
-
157
- Args:
158
- products_data: Parsed products.json data
159
-
160
- Returns:
161
- Python code string with ProductInfo instances
162
- """
163
- code_lines = []
164
-
165
- # Handle both array and object formats
166
- all_vendors = []
167
- if isinstance(products_data, list):
168
- all_vendors = products_data
169
- else:
170
- all_vendors = [products_data]
171
-
172
- # Generate product definitions
173
- code_lines.append("# Pre-generated product definitions")
174
- code_lines.append("PRODUCTS: dict[int, ProductInfo] = {")
175
-
176
- product_count = 0
177
- skipped_count = 0
178
- for vendor_data in all_vendors:
179
- vendor_id = vendor_data.get("vid", 1)
180
- defaults = vendor_data.get("defaults", {})
181
- default_features = defaults.get("features", {})
182
-
183
- # Process each product
184
- for product in vendor_data.get("products", []):
185
- pid = product["pid"]
186
- name = product["name"]
187
- features = {**default_features, **product.get("features", {})}
188
-
189
- # Skip switch products (devices with relays) - these are not lights
190
- if features.get("relays"):
191
- skipped_count += 1
192
- continue
193
-
194
- # Build capabilities
195
- capabilities = _build_capabilities(features)
196
-
197
- # Check for extended multizone
198
- has_ext_mz, min_ext_mz_firmware = _check_extended_multizone(
199
- product, features
200
- )
201
- if has_ext_mz:
202
- capabilities.append("ProductCapability.EXTENDED_MULTIZONE")
203
-
204
- # Build capabilities expression
205
- capabilities_expr = " | ".join(capabilities) if capabilities else "0"
206
-
207
- # Format temperature range
208
- temp_range_expr = _format_temperature_range(features)
209
-
210
- # Generate code for this product
211
- product_code = _generate_product_code(
212
- pid,
213
- name,
214
- vendor_id,
215
- capabilities_expr,
216
- temp_range_expr,
217
- min_ext_mz_firmware,
218
- )
219
- code_lines.extend(product_code)
220
- product_count += 1
221
-
222
- code_lines.append("}")
223
- code_lines.append("")
224
-
225
- print(f"Generated {product_count} product definitions")
226
- if skipped_count > 0:
227
- print(f"Skipped {skipped_count} switch products (relays only)")
228
- return "\n".join(code_lines)
229
-
230
-
231
- def generate_registry_file(products_data: dict[str, Any] | list[dict[str, Any]]) -> str:
232
- """Generate complete registry.py file.
233
-
234
- Args:
235
- products_data: Parsed products.json data
236
-
237
- Returns:
238
- Complete Python file content
239
- """
240
- header = '''"""LIFX product definitions and capability detection.
241
-
242
- DO NOT EDIT THIS FILE MANUALLY.
243
- Generated from https://github.com/LIFX/products/blob/master/products.json
244
- by products/generator.py
245
-
246
- This module provides pre-generated product information for efficient runtime lookups.
247
- """
248
-
249
- from __future__ import annotations
250
-
251
- from dataclasses import dataclass
252
- from enum import IntEnum
253
- from functools import cached_property
254
-
255
-
256
- class ProductCapability(IntEnum):
257
- """Product capability flags."""
258
-
259
- COLOR = 1
260
- INFRARED = 2
261
- MULTIZONE = 4
262
- CHAIN = 8
263
- MATRIX = 16
264
- RELAYS = 32
265
- BUTTONS = 64
266
- HEV = 128
267
- EXTENDED_MULTIZONE = 256
268
-
269
-
270
- @dataclass
271
- class TemperatureRange:
272
- """Color temperature range in Kelvin."""
273
-
274
- min: int
275
- max: int
276
-
277
-
278
- @dataclass
279
- class ProductInfo:
280
- """Information about a LIFX product.
281
-
282
- Attributes:
283
- pid: Product ID
284
- name: Product name
285
- vendor: Vendor ID (always 1 for LIFX)
286
- capabilities: Bitfield of ProductCapability flags
287
- temperature_range: Min/max color temperature in Kelvin
288
- min_ext_mz_firmware: Minimum firmware version for extended multizone
289
- """
290
-
291
- pid: int
292
- name: str
293
- vendor: int
294
- capabilities: int
295
- temperature_range: TemperatureRange | None
296
- min_ext_mz_firmware: int | None
297
-
298
- def has_capability(self, capability: ProductCapability) -> bool:
299
- """Check if product has a specific capability.
300
-
301
- Args:
302
- capability: Capability to check
303
-
304
- Returns:
305
- True if product has the capability
306
- """
307
- return bool(self.capabilities & capability)
308
-
309
- @property
310
- def has_color(self) -> bool:
311
- """Check if product supports color."""
312
- return self.has_capability(ProductCapability.COLOR)
313
-
314
- @property
315
- def has_infrared(self) -> bool:
316
- """Check if product supports infrared."""
317
- return self.has_capability(ProductCapability.INFRARED)
318
-
319
- @property
320
- def has_multizone(self) -> bool:
321
- """Check if product supports multizone."""
322
- return self.has_capability(ProductCapability.MULTIZONE)
323
-
324
- @property
325
- def has_chain(self) -> bool:
326
- """Check if product supports chaining."""
327
- return self.has_capability(ProductCapability.CHAIN)
328
-
329
- @property
330
- def has_matrix(self) -> bool:
331
- """Check if product supports matrix (2D grid)."""
332
- return self.has_capability(ProductCapability.MATRIX)
333
-
334
- @property
335
- def has_relays(self) -> bool:
336
- """Check if product has relays."""
337
- return self.has_capability(ProductCapability.RELAYS)
338
-
339
- @property
340
- def has_buttons(self) -> bool:
341
- """Check if product has buttons."""
342
- return self.has_capability(ProductCapability.BUTTONS)
343
-
344
- @property
345
- def has_hev(self) -> bool:
346
- """Check if product supports HEV."""
347
- return self.has_capability(ProductCapability.HEV)
348
-
349
- @property
350
- def has_extended_multizone(self) -> bool:
351
- """Check if product supports extended multizone."""
352
- return self.has_capability(ProductCapability.EXTENDED_MULTIZONE)
353
-
354
- def supports_extended_multizone(self, firmware_version: int | None = None) -> bool:
355
- """Check if extended multizone is supported for given firmware version.
356
-
357
- Args:
358
- firmware_version: Firmware version to check (optional)
359
-
360
- Returns:
361
- True if extended multizone is supported
362
- """
363
- if not self.has_extended_multizone:
364
- return False
365
- if self.min_ext_mz_firmware is None:
366
- return True
367
- if firmware_version is None:
368
- return True
369
- return firmware_version >= self.min_ext_mz_firmware
370
-
371
- @cached_property
372
- def caps(self) -> str:
373
- """Format product capabilities as a human-readable string.
374
-
375
- Returns:
376
- Comma-separated capability string (e.g., "full color, infrared, multizone")
377
- """
378
- caps = []
379
-
380
- # Determine base light type
381
- if self.has_relays:
382
- # Devices with relays are switches, not lights
383
- caps.append("switch")
384
- elif self.has_color:
385
- caps.append("full color")
386
- else:
387
- # Check temperature range to determine white light type
388
- if self.temperature_range:
389
- if self.temperature_range.min != self.temperature_range.max:
390
- caps.append("color temperature")
391
- else:
392
- caps.append("brightness only")
393
- else:
394
- # No temperature range info, assume basic brightness
395
- caps.append("brightness only")
396
-
397
- # Add additional capabilities
398
- if self.has_infrared:
399
- caps.append("infrared")
400
- # Extended multizone is backwards compatible with multizone,
401
- # so only show multizone if extended multizone is not present
402
- if self.has_extended_multizone:
403
- caps.append("extended-multizone")
404
- elif self.has_multizone:
405
- caps.append("multizone")
406
- if self.has_matrix:
407
- caps.append("matrix")
408
- if self.has_hev:
409
- caps.append("HEV")
410
- if self.has_chain:
411
- caps.append("chain")
412
- if self.has_buttons and not self.has_relays:
413
- # Only show buttons if not already identified as switch
414
- caps.append("buttons")
415
-
416
- return ", ".join(caps) if caps else "unknown"
417
-
418
-
419
- '''
420
-
421
- # Generate product definitions
422
- products_code = generate_product_definitions(products_data)
423
-
424
- # Generate helper functions
425
- helper_functions = '''
426
-
427
- class ProductRegistry:
428
- """Registry of LIFX products and their capabilities."""
429
-
430
- def __init__(self) -> None:
431
- """Initialize product registry with pre-generated data."""
432
- self._products = PRODUCTS.copy() # Copy to allow test overrides
433
- self._loaded = True # Always loaded in generated registry
434
-
435
- def load_from_dict(self, data: dict | list) -> None:
436
- """Load products from parsed JSON data (for testing).
437
-
438
- Args:
439
- data: Parsed products.json dictionary or array
440
- """
441
- from typing import Any
442
-
443
- # Clear existing products
444
- self._products.clear()
445
-
446
- # Handle both array and object formats
447
- all_vendors = []
448
- if isinstance(data, list):
449
- all_vendors = data
450
- else:
451
- all_vendors = [data]
452
-
453
- # Process each vendor
454
- for vendor_data in all_vendors:
455
- vendor_id = vendor_data.get("vid", 1)
456
- defaults = vendor_data.get("defaults", {})
457
- default_features = defaults.get("features", {})
458
-
459
- # Parse each product
460
- for product in vendor_data.get("products", []):
461
- pid = product["pid"]
462
- name = product["name"]
463
-
464
- # Merge features with defaults
465
- prod_features = product.get("features", {})
466
- features: dict[str, Any] = {**default_features, **prod_features}
467
-
468
- # Skip switch products (devices with relays) - these are not lights
469
- if features.get("relays"):
470
- continue
471
-
472
- # Build capabilities bitfield
473
- capabilities = 0
474
- if features.get("color"):
475
- capabilities |= ProductCapability.COLOR
476
- if features.get("infrared"):
477
- capabilities |= ProductCapability.INFRARED
478
- if features.get("multizone"):
479
- capabilities |= ProductCapability.MULTIZONE
480
- if features.get("chain"):
481
- capabilities |= ProductCapability.CHAIN
482
- if features.get("matrix"):
483
- capabilities |= ProductCapability.MATRIX
484
- if features.get("relays"):
485
- capabilities |= ProductCapability.RELAYS
486
- if features.get("buttons"):
487
- capabilities |= ProductCapability.BUTTONS
488
- if features.get("hev"):
489
- capabilities |= ProductCapability.HEV
490
-
491
- # Check for extended multizone capability
492
- min_ext_mz_firmware = None
493
-
494
- # First check if it's a native feature (no firmware requirement)
495
- if features.get("extended_multizone"):
496
- capabilities |= ProductCapability.EXTENDED_MULTIZONE
497
- else:
498
- # Check if it's available as an upgrade (requires minimum firmware)
499
- for upgrade in product.get("upgrades", []):
500
- if upgrade.get("features", {}).get("extended_multizone"):
501
- capabilities |= ProductCapability.EXTENDED_MULTIZONE
502
- # Parse firmware version (major.minor format)
503
- major = upgrade.get("major", 0)
504
- minor = upgrade.get("minor", 0)
505
- min_ext_mz_firmware = (major << 16) | minor
506
- break
507
-
508
- # Parse temperature range
509
- temp_range = None
510
- if "temperature_range" in features:
511
- temp_list = features["temperature_range"]
512
- if len(temp_list) >= 2:
513
- temp_range = TemperatureRange(
514
- min=temp_list[0], max=temp_list[1]
515
- )
516
-
517
- product_info = ProductInfo(
518
- pid=pid,
519
- name=name,
520
- vendor=vendor_id,
521
- capabilities=capabilities,
522
- temperature_range=temp_range,
523
- min_ext_mz_firmware=min_ext_mz_firmware,
524
- )
525
-
526
- self._products[pid] = product_info
527
-
528
- self._loaded = True
529
-
530
- @property
531
- def is_loaded(self) -> bool:
532
- """Check if registry has been loaded."""
533
- return self._loaded
534
-
535
- def get_product(self, pid: int) -> ProductInfo | None:
536
- """Get product info by product ID.
537
-
538
- Args:
539
- pid: Product ID
540
-
541
- Returns:
542
- ProductInfo if found, None otherwise
543
- """
544
- return self._products.get(pid)
545
-
546
- def get_device_class_name(
547
- self, pid: int, firmware_version: int | None = None
548
- ) -> str:
549
- """Get appropriate device class name for a product.
550
-
551
- Args:
552
- pid: Product ID
553
- firmware_version: Firmware version (optional)
554
-
555
- Returns:
556
- Device class name: "TileDevice", "MultiZoneLight", "HevLight",
557
- "InfraredLight", "Light", or "Device"
558
- """
559
- product = self.get_product(pid)
560
- if product is None:
561
- # Unknown product - default to Light if we don't know
562
- return "Light"
563
-
564
- # Matrix devices (Tiles, Candles) → TileDevice
565
- if product.has_matrix:
566
- return "TileDevice"
567
-
568
- # MultiZone devices (Strips, Beams) → MultiZoneLight
569
- if product.has_multizone:
570
- return "MultiZoneLight"
571
-
572
- # HEV lights → HevLight
573
- if product.has_hev:
574
- return "HevLight"
575
-
576
- # Infrared lights → InfraredLight
577
- if product.has_infrared:
578
- return "InfraredLight"
579
-
580
- # Color lights → Light
581
- if product.has_color:
582
- return "Light"
583
-
584
- # Devices with relays (switches/relays) → Device
585
- if product.has_relays:
586
- return "Device"
587
-
588
- # Devices with buttons but no color (switches) → Device
589
- if product.has_buttons:
590
- return "Device"
591
-
592
- # Everything else (basic lights, white-to-warm lights) → Light
593
- # These have no special capabilities but still support Light protocol
594
- return "Light"
595
-
596
- def __len__(self) -> int:
597
- """Get number of products in registry."""
598
- return len(self._products)
599
-
600
- def __contains__(self, pid: int) -> bool:
601
- """Check if product ID exists in registry."""
602
- return pid in self._products
603
-
604
-
605
- # Global registry instance
606
- _registry = ProductRegistry()
607
-
608
-
609
- def get_registry() -> ProductRegistry:
610
- """Get the global product registry.
611
-
612
- Returns:
613
- Global ProductRegistry instance
614
- """
615
- return _registry
616
-
617
-
618
- def get_product(pid: int) -> ProductInfo | None:
619
- """Get product info by product ID.
620
-
621
- Args:
622
- pid: Product ID
623
-
624
- Returns:
625
- ProductInfo if found, None otherwise
626
- """
627
- return _registry.get_product(pid)
628
-
629
-
630
- def get_device_class_name(pid: int, firmware_version: int | None = None) -> str:
631
- """Get appropriate device class name for a product.
632
-
633
- Args:
634
- pid: Product ID
635
- firmware_version: Firmware version (optional)
636
-
637
- Returns:
638
- Device class name: "TileDevice", "MultiZoneLight", "Light", or "Device"
639
- """
640
- return _registry.get_device_class_name(pid, firmware_version)
641
- '''
642
-
643
- return header + products_code + helper_functions
644
-
645
-
646
- def _load_existing_specs(specs_path: Path) -> dict[int, dict[str, Any]]:
647
- """Load existing specs from YAML file.
648
-
649
- Args:
650
- specs_path: Path to specs.yml file
651
-
652
- Returns:
653
- Dictionary of product specs keyed by PID
654
- """
655
- if not specs_path.exists():
656
- return {}
657
-
658
- with open(specs_path) as f:
659
- specs_data = yaml.safe_load(f)
660
- if specs_data and "products" in specs_data:
661
- return specs_data["products"]
662
-
663
- return {}
664
-
665
-
666
- def _discover_new_products(
667
- products_data: dict[str, Any] | list[dict[str, Any]],
668
- existing_specs: dict[int, dict[str, Any]],
669
- ) -> list[dict[str, Any]]:
670
- """Find new multizone or matrix products that need specs templates.
671
-
672
- Args:
673
- products_data: Parsed products.json data
674
- existing_specs: Existing product specs
675
-
676
- Returns:
677
- List of new product dictionaries with metadata
678
- """
679
- all_vendors = []
680
- if isinstance(products_data, list):
681
- all_vendors = products_data
682
- else:
683
- all_vendors = [products_data]
684
-
685
- new_products = []
686
- for vendor_data in all_vendors:
687
- defaults = vendor_data.get("defaults", {})
688
- default_features = defaults.get("features", {})
689
-
690
- for product in vendor_data.get("products", []):
691
- pid = product["pid"]
692
- features = {**default_features, **product.get("features", {})}
693
-
694
- # Skip switch products (devices with relays) - these are not lights
695
- if features.get("relays"):
696
- continue
697
-
698
- # Check if this product needs specs template
699
- if pid not in existing_specs:
700
- is_multizone = features.get("multizone", False)
701
- is_matrix = features.get("matrix", False)
702
-
703
- if is_multizone or is_matrix:
704
- new_product = {
705
- "pid": pid,
706
- "name": product["name"],
707
- "multizone": is_multizone,
708
- "matrix": is_matrix,
709
- "extended_multizone": False,
710
- }
711
-
712
- # Check for extended multizone in upgrades
713
- for upgrade in product.get("upgrades", []):
714
- if upgrade.get("features", {}).get("extended_multizone"):
715
- new_product["extended_multizone"] = True
716
- break
717
-
718
- new_products.append(new_product)
719
-
720
- return new_products
721
-
722
-
723
- def _add_product_templates(
724
- new_products: list[dict[str, Any]], existing_specs: dict[int, dict[str, Any]]
725
- ) -> None:
726
- """Add templates for new products to existing specs.
727
-
728
- Args:
729
- new_products: List of new product dictionaries
730
- existing_specs: Existing product specs (modified in place)
731
- """
732
- for product in new_products:
733
- product_name = product["name"].replace('"', '\\"')
734
-
735
- if product["multizone"]:
736
- existing_specs[product["pid"]] = {
737
- "default_zone_count": 16,
738
- "min_zone_count": 1,
739
- "max_zone_count": 16,
740
- "notes": product_name,
741
- }
742
- elif product["matrix"]:
743
- existing_specs[product["pid"]] = {
744
- "default_tile_count": 1,
745
- "min_tile_count": 1,
746
- "max_tile_count": 1,
747
- "tile_width": 8,
748
- "tile_height": 8,
749
- "notes": product_name,
750
- }
751
-
752
-
753
- def _categorize_products(
754
- existing_specs: dict[int, dict[str, Any]],
755
- ) -> tuple[list[int], list[int]]:
756
- """Categorize products into multizone and matrix.
757
-
758
- Args:
759
- existing_specs: Product specs dictionary
760
-
761
- Returns:
762
- Tuple of (sorted_multizone_pids, sorted_matrix_pids)
763
- """
764
- multizone_pids = []
765
- matrix_pids = []
766
-
767
- for pid, specs in existing_specs.items():
768
- if "tile_width" in specs or "tile_height" in specs:
769
- matrix_pids.append(pid)
770
- elif "default_zone_count" in specs:
771
- multizone_pids.append(pid)
772
-
773
- multizone_pids.sort()
774
- matrix_pids.sort()
775
-
776
- return multizone_pids, matrix_pids
777
-
778
-
779
- def _generate_yaml_header() -> list[str]:
780
- """Generate YAML file header with documentation.
781
-
782
- Returns:
783
- List of header lines
784
- """
785
- return [
786
- "# LIFX Product Specs and Defaults",
787
- "# =================================",
788
- "#",
789
- "# This file contains product-specific details that are not available in the",
790
- "# upstream LIFX products.json catalog, such as default zone counts, tile",
791
- "# configurations, firmware versions, and other device-specific defaults.",
792
- "#",
793
- "# These values are used by the emulator to create realistic device",
794
- "# configurations when specific parameters are not provided by the user.",
795
- "#",
796
- "# Format:",
797
- "# -------",
798
- "# products:",
799
- "# <product_id>:",
800
- "# # For multizone devices",
801
- "# default_zone_count: <number> # Default zones (e.g., 16)",
802
- "# min_zone_count: <number> # Minimum zones supported",
803
- "# max_zone_count: <number> # Maximum zones supported",
804
- "#",
805
- "# # For matrix devices (tiles, candles, etc.)",
806
- "# default_tile_count: <number> # Default number of tiles in chain",
807
- "# min_tile_count: <number> # Minimum tiles supported",
808
- "# max_tile_count: <number> # Maximum tiles supported",
809
- "# tile_width: <number> # Width of each tile in zones",
810
- "# tile_height: <number> # Height of each tile in zones",
811
- "#",
812
- "# # Host firmware version (optional, overrides auto firmware selection)",
813
- "# default_firmware_major: <number> # Firmware major version (e.g., 3)",
814
- "# default_firmware_minor: <number> # Firmware minor version (e.g., 70)",
815
- "#",
816
- "# # Other device-specific defaults",
817
- '# notes: "<string>" # Notes about product',
818
- "#",
819
- "# Firmware Version Notes:",
820
- "# ----------------------",
821
- "# If default_firmware_major and default_firmware_minor are both specified ",
822
- "# they will be used as the default firmware version when creating devices",
823
- "# of that type. This overrides the automatic firmware version selection",
824
- "# based on extended_multizone capability (which defaults to 3.70 for extended",
825
- "# multizone or 2.60 for non-extended).",
826
- "#",
827
- "# Precedence order for firmware version:",
828
- "# 1. Explicit firmware_version parameter to create_device()",
829
- "# 2. Product-specific default from this specs.yml file",
830
- "# 3. Automatic selection based on extended_multizone flag",
831
- "",
832
- "products:",
833
- ]
834
-
835
-
836
- def _generate_multizone_section(
837
- multizone_pids: list[int], existing_specs: dict[int, dict[str, Any]]
838
- ) -> list[str]:
839
- """Generate YAML lines for multizone products section.
840
-
841
- Args:
842
- multizone_pids: Sorted list of multizone product IDs
843
- existing_specs: Product specs dictionary
844
-
845
- Returns:
846
- List of YAML lines
847
- """
848
- if not multizone_pids:
849
- return []
850
-
851
- lines = [
852
- " # ========================================",
853
- " # Multizone Products (Linear Strips)",
854
- " # ========================================",
855
- "",
856
- ]
857
-
858
- for pid in multizone_pids:
859
- specs = existing_specs[pid]
860
- name = specs.get("notes", f"Product {pid}").split(" - ")[0]
861
-
862
- lines.append(f" {pid}: # {name}")
863
- lines.append(f" default_zone_count: {specs['default_zone_count']}")
864
- lines.append(f" min_zone_count: {specs['min_zone_count']}")
865
- lines.append(f" max_zone_count: {specs['max_zone_count']}")
866
-
867
- # Add firmware version if present
868
- if "default_firmware_major" in specs and "default_firmware_minor" in specs:
869
- lines.append(
870
- f" default_firmware_major: {specs['default_firmware_major']}"
871
- )
872
- lines.append(
873
- f" default_firmware_minor: {specs['default_firmware_minor']}"
874
- )
875
-
876
- notes = specs.get("notes", "")
877
- if notes:
878
- notes_escaped = notes.replace('"', '\\"')
879
- lines.append(f' notes: "{notes_escaped}"')
880
- lines.append("")
881
-
882
- return lines
883
-
884
-
885
- def _generate_matrix_section(
886
- matrix_pids: list[int], existing_specs: dict[int, dict[str, Any]]
887
- ) -> list[str]:
888
- """Generate YAML lines for matrix products section.
889
-
890
- Args:
891
- matrix_pids: Sorted list of matrix product IDs
892
- existing_specs: Product specs dictionary
893
-
894
- Returns:
895
- List of YAML lines
896
- """
897
- if not matrix_pids:
898
- return []
899
-
900
- lines = [
901
- " # ========================================",
902
- " # Matrix Products (Tiles, Candles, etc.)",
903
- " # ========================================",
904
- "",
905
- ]
906
-
907
- for pid in matrix_pids:
908
- specs = existing_specs[pid]
909
- name = specs.get("notes", f"Product {pid}").split(" - ")[0]
910
-
911
- lines.append(f" {pid}: # {name}")
912
- lines.append(f" default_tile_count: {specs['default_tile_count']}")
913
- lines.append(f" min_tile_count: {specs['min_tile_count']}")
914
- lines.append(f" max_tile_count: {specs['max_tile_count']}")
915
- lines.append(f" tile_width: {specs['tile_width']}")
916
- lines.append(f" tile_height: {specs['tile_height']}")
917
-
918
- # Add firmware version if present
919
- if "default_firmware_major" in specs and "default_firmware_minor" in specs:
920
- lines.append(
921
- f" default_firmware_major: {specs['default_firmware_major']}"
922
- )
923
- lines.append(
924
- f" default_firmware_minor: {specs['default_firmware_minor']}"
925
- )
926
-
927
- notes = specs.get("notes", "")
928
- if notes:
929
- notes_escaped = notes.replace('"', '\\"')
930
- lines.append(f' notes: "{notes_escaped}"')
931
- lines.append("")
932
-
933
- return lines
934
-
935
-
936
- def update_specs_file(
937
- products_data: dict[str, Any] | list[dict[str, Any]], specs_path: Path
938
- ) -> None:
939
- """Update specs.yml with templates for new products and sort all entries by PID.
940
-
941
- Args:
942
- products_data: Parsed products.json data
943
- specs_path: Path to specs.yml file
944
- """
945
- # Load existing specs
946
- existing_specs = _load_existing_specs(specs_path)
947
-
948
- # Find new products that need specs
949
- new_products = _discover_new_products(products_data, existing_specs)
950
-
951
- # Print status
952
- if not new_products:
953
- print("No new multizone or matrix products found - specs.yml is up to date")
954
- if existing_specs:
955
- print("Sorting existing specs entries by product ID...")
956
- else:
957
- return
958
- else:
959
- print(f"\nFound {len(new_products)} new products that need specs:")
960
- for product in new_products:
961
- print(f" PID {product['pid']:>3}: {product['name']}")
962
-
963
- # Add templates for new products
964
- _add_product_templates(new_products, existing_specs)
965
-
966
- # Categorize products and sort
967
- multizone_pids, matrix_pids = _categorize_products(existing_specs)
968
-
969
- # Build YAML content
970
- lines = _generate_yaml_header()
971
- lines.extend(_generate_multizone_section(multizone_pids, existing_specs))
972
- lines.extend(_generate_matrix_section(matrix_pids, existing_specs))
973
-
974
- # Write the new file
975
- with open(specs_path, "w") as f:
976
- f.write("\n".join(lines))
977
-
978
- # Print completion message
979
- if new_products:
980
- print(
981
- f"\n✓ Added {len(new_products)} new product templates "
982
- f"and sorted all entries by PID"
983
- )
984
- print(
985
- " Please review and update the placeholder values "
986
- "with actual product specifications"
987
- )
988
- else:
989
- print(f"\n✓ Sorted all {len(existing_specs)} specs entries by product ID")
990
-
991
-
992
- def main() -> None:
993
- """Main generator entry point."""
994
- try:
995
- # Download and parse products from GitHub
996
- products_data = download_products()
997
- except Exception as e:
998
- print(f"Error: Failed to download products.json: {e}", file=sys.stderr)
999
- sys.exit(1)
1000
-
1001
- # Count products for summary
1002
- if isinstance(products_data, list):
1003
- all_products = []
1004
- for vendor in products_data:
1005
- all_products.extend(vendor.get("products", []))
1006
- else:
1007
- all_products = products_data.get("products", [])
1008
-
1009
- print(f"Found {len(all_products)} products")
1010
-
1011
- # Generate registry.py
1012
- print("\nGenerating registry.py...")
1013
- registry_code = generate_registry_file(products_data)
1014
-
1015
- # Determine output path
1016
- registry_path = Path(__file__).parent / "registry.py"
1017
-
1018
- with open(registry_path, "w") as f:
1019
- f.write(registry_code)
1020
-
1021
- print(f"✓ Generated {registry_path}")
1022
-
1023
- # Update specs.yml with templates for new products
1024
- print("\nChecking for new products that need specs...")
1025
- specs_path = Path(__file__).parent / "specs.yml"
1026
-
1027
- try:
1028
- update_specs_file(products_data, specs_path)
1029
- except Exception as e:
1030
- print(f"Warning: Failed to update specs.yml: {e}", file=sys.stderr)
1031
- print("You can manually add specs for new products")
1032
-
1033
- print("\n✓ Generation complete!")
1034
-
1035
-
1036
- if __name__ == "__main__":
1037
- main()