lifx-emulator 1.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 (40) hide show
  1. lifx_emulator/__init__.py +31 -0
  2. lifx_emulator/__main__.py +607 -0
  3. lifx_emulator/api.py +1825 -0
  4. lifx_emulator/async_storage.py +308 -0
  5. lifx_emulator/constants.py +33 -0
  6. lifx_emulator/device.py +750 -0
  7. lifx_emulator/device_states.py +114 -0
  8. lifx_emulator/factories.py +380 -0
  9. lifx_emulator/handlers/__init__.py +39 -0
  10. lifx_emulator/handlers/base.py +49 -0
  11. lifx_emulator/handlers/device_handlers.py +340 -0
  12. lifx_emulator/handlers/light_handlers.py +372 -0
  13. lifx_emulator/handlers/multizone_handlers.py +249 -0
  14. lifx_emulator/handlers/registry.py +110 -0
  15. lifx_emulator/handlers/tile_handlers.py +309 -0
  16. lifx_emulator/observers.py +139 -0
  17. lifx_emulator/products/__init__.py +28 -0
  18. lifx_emulator/products/generator.py +771 -0
  19. lifx_emulator/products/registry.py +1446 -0
  20. lifx_emulator/products/specs.py +242 -0
  21. lifx_emulator/products/specs.yml +327 -0
  22. lifx_emulator/protocol/__init__.py +1 -0
  23. lifx_emulator/protocol/base.py +334 -0
  24. lifx_emulator/protocol/const.py +8 -0
  25. lifx_emulator/protocol/generator.py +1371 -0
  26. lifx_emulator/protocol/header.py +159 -0
  27. lifx_emulator/protocol/packets.py +1351 -0
  28. lifx_emulator/protocol/protocol_types.py +844 -0
  29. lifx_emulator/protocol/serializer.py +379 -0
  30. lifx_emulator/scenario_manager.py +402 -0
  31. lifx_emulator/scenario_persistence.py +206 -0
  32. lifx_emulator/server.py +482 -0
  33. lifx_emulator/state_restorer.py +259 -0
  34. lifx_emulator/state_serializer.py +130 -0
  35. lifx_emulator/storage_protocol.py +100 -0
  36. lifx_emulator-1.0.0.dist-info/METADATA +445 -0
  37. lifx_emulator-1.0.0.dist-info/RECORD +40 -0
  38. lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
  39. lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
  40. lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
@@ -0,0 +1,771 @@
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 download_products() -> dict[str, Any] | list[dict[str, Any]]:
21
+ """Download and parse products.json from LIFX GitHub repository.
22
+
23
+ Returns:
24
+ Parsed products dictionary or list
25
+
26
+ Raises:
27
+ URLError: If download fails
28
+ json.JSONDecodeError: If parsing fails
29
+ """
30
+ print(f"Downloading products.json from {PRODUCTS_URL}...")
31
+ with urlopen(PRODUCTS_URL) as response: # nosec
32
+ products_data = response.read()
33
+
34
+ print("Parsing products specification...")
35
+ products = json.loads(products_data)
36
+ return products
37
+
38
+
39
+ def generate_product_definitions(
40
+ products_data: dict[str, Any] | list[dict[str, Any]],
41
+ ) -> str:
42
+ """Generate Python code for product definitions.
43
+
44
+ Args:
45
+ products_data: Parsed products.json data
46
+
47
+ Returns:
48
+ Python code string with ProductInfo instances
49
+ """
50
+ code_lines = []
51
+
52
+ # Handle both array and object formats
53
+ all_vendors = []
54
+ if isinstance(products_data, list):
55
+ # Array format - multiple vendors
56
+ all_vendors = products_data
57
+ else:
58
+ # Object format - single vendor
59
+ all_vendors = [products_data]
60
+
61
+ # Generate product definitions
62
+ code_lines.append("# Pre-generated product definitions")
63
+ code_lines.append("PRODUCTS: dict[int, ProductInfo] = {")
64
+
65
+ product_count = 0
66
+ for vendor_data in all_vendors:
67
+ vendor_id = vendor_data.get("vid", 1)
68
+
69
+ # Get default features
70
+ defaults = vendor_data.get("defaults", {})
71
+ default_features = defaults.get("features", {})
72
+
73
+ # Process each product
74
+ for product in vendor_data.get("products", []):
75
+ pid = product["pid"]
76
+ name = product["name"]
77
+
78
+ # Merge features with defaults
79
+ features = {**default_features, **product.get("features", {})}
80
+
81
+ # Build capabilities bitfield
82
+ capabilities = []
83
+ if features.get("color"):
84
+ capabilities.append("ProductCapability.COLOR")
85
+ if features.get("infrared"):
86
+ capabilities.append("ProductCapability.INFRARED")
87
+ if features.get("multizone"):
88
+ capabilities.append("ProductCapability.MULTIZONE")
89
+ if features.get("chain"):
90
+ capabilities.append("ProductCapability.CHAIN")
91
+ if features.get("matrix"):
92
+ capabilities.append("ProductCapability.MATRIX")
93
+ if features.get("relays"):
94
+ capabilities.append("ProductCapability.RELAYS")
95
+ if features.get("buttons"):
96
+ capabilities.append("ProductCapability.BUTTONS")
97
+ if features.get("hev"):
98
+ capabilities.append("ProductCapability.HEV")
99
+
100
+ # Check for extended multizone in upgrades
101
+ min_ext_mz_firmware = None
102
+ for upgrade in product.get("upgrades", []):
103
+ if upgrade.get("features", {}).get("extended_multizone"):
104
+ capabilities.append("ProductCapability.EXTENDED_MULTIZONE")
105
+ # Parse firmware version (major.minor format)
106
+ major = upgrade.get("major", 0)
107
+ minor = upgrade.get("minor", 0)
108
+ min_ext_mz_firmware = (major << 16) | minor
109
+ break
110
+
111
+ # Build capabilities expression
112
+ if capabilities:
113
+ capabilities_expr = " | ".join(capabilities)
114
+ else:
115
+ capabilities_expr = "0"
116
+
117
+ # Parse temperature range
118
+ temp_range_expr = "None"
119
+ if "temperature_range" in features:
120
+ temp_list = features["temperature_range"]
121
+ if len(temp_list) >= 2:
122
+ temp_range_expr = (
123
+ f"TemperatureRange(min={temp_list[0]}, max={temp_list[1]})"
124
+ )
125
+
126
+ # Format firmware version
127
+ min_ext_mz_firmware_expr = (
128
+ str(min_ext_mz_firmware) if min_ext_mz_firmware is not None else "None"
129
+ )
130
+
131
+ # Generate ProductInfo instantiation
132
+ code_lines.append(f" {pid}: ProductInfo(")
133
+ code_lines.append(f" pid={pid},")
134
+ code_lines.append(f" name={repr(name)},")
135
+ code_lines.append(f" vendor={vendor_id},")
136
+ code_lines.append(f" capabilities={capabilities_expr},")
137
+ code_lines.append(f" temperature_range={temp_range_expr},")
138
+ code_lines.append(
139
+ f" min_ext_mz_firmware={min_ext_mz_firmware_expr},"
140
+ )
141
+ code_lines.append(" ),")
142
+
143
+ product_count += 1
144
+
145
+ code_lines.append("}")
146
+ code_lines.append("")
147
+
148
+ print(f"Generated {product_count} product definitions")
149
+ return "\n".join(code_lines)
150
+
151
+
152
+ def generate_registry_file(products_data: dict[str, Any] | list[dict[str, Any]]) -> str:
153
+ """Generate complete registry.py file.
154
+
155
+ Args:
156
+ products_data: Parsed products.json data
157
+
158
+ Returns:
159
+ Complete Python file content
160
+ """
161
+ header = '''"""LIFX product definitions and capability detection.
162
+
163
+ DO NOT EDIT THIS FILE MANUALLY.
164
+ Generated from https://github.com/LIFX/products/blob/master/products.json
165
+ by products/generator.py
166
+
167
+ This module provides pre-generated product information for efficient runtime lookups.
168
+ """
169
+
170
+ from __future__ import annotations
171
+
172
+ from dataclasses import dataclass
173
+ from enum import IntEnum
174
+
175
+
176
+ class ProductCapability(IntEnum):
177
+ """Product capability flags."""
178
+
179
+ COLOR = 1
180
+ INFRARED = 2
181
+ MULTIZONE = 4
182
+ CHAIN = 8
183
+ MATRIX = 16
184
+ RELAYS = 32
185
+ BUTTONS = 64
186
+ HEV = 128
187
+ EXTENDED_MULTIZONE = 256
188
+
189
+
190
+ @dataclass
191
+ class TemperatureRange:
192
+ """Color temperature range in Kelvin."""
193
+
194
+ min: int
195
+ max: int
196
+
197
+
198
+ @dataclass
199
+ class ProductInfo:
200
+ """Information about a LIFX product.
201
+
202
+ Attributes:
203
+ pid: Product ID
204
+ name: Product name
205
+ vendor: Vendor ID (always 1 for LIFX)
206
+ capabilities: Bitfield of ProductCapability flags
207
+ temperature_range: Min/max color temperature in Kelvin
208
+ min_ext_mz_firmware: Minimum firmware version for extended multizone
209
+ """
210
+
211
+ pid: int
212
+ name: str
213
+ vendor: int
214
+ capabilities: int
215
+ temperature_range: TemperatureRange | None
216
+ min_ext_mz_firmware: int | None
217
+
218
+ def has_capability(self, capability: ProductCapability) -> bool:
219
+ """Check if product has a specific capability.
220
+
221
+ Args:
222
+ capability: Capability to check
223
+
224
+ Returns:
225
+ True if product has the capability
226
+ """
227
+ return bool(self.capabilities & capability)
228
+
229
+ @property
230
+ def has_color(self) -> bool:
231
+ """Check if product supports color."""
232
+ return self.has_capability(ProductCapability.COLOR)
233
+
234
+ @property
235
+ def has_infrared(self) -> bool:
236
+ """Check if product supports infrared."""
237
+ return self.has_capability(ProductCapability.INFRARED)
238
+
239
+ @property
240
+ def has_multizone(self) -> bool:
241
+ """Check if product supports multizone."""
242
+ return self.has_capability(ProductCapability.MULTIZONE)
243
+
244
+ @property
245
+ def has_chain(self) -> bool:
246
+ """Check if product supports chaining."""
247
+ return self.has_capability(ProductCapability.CHAIN)
248
+
249
+ @property
250
+ def has_matrix(self) -> bool:
251
+ """Check if product supports matrix (2D grid)."""
252
+ return self.has_capability(ProductCapability.MATRIX)
253
+
254
+ @property
255
+ def has_relays(self) -> bool:
256
+ """Check if product has relays."""
257
+ return self.has_capability(ProductCapability.RELAYS)
258
+
259
+ @property
260
+ def has_buttons(self) -> bool:
261
+ """Check if product has buttons."""
262
+ return self.has_capability(ProductCapability.BUTTONS)
263
+
264
+ @property
265
+ def has_hev(self) -> bool:
266
+ """Check if product supports HEV."""
267
+ return self.has_capability(ProductCapability.HEV)
268
+
269
+ @property
270
+ def has_extended_multizone(self) -> bool:
271
+ """Check if product supports extended multizone."""
272
+ return self.has_capability(ProductCapability.EXTENDED_MULTIZONE)
273
+
274
+ def supports_extended_multizone(self, firmware_version: int | None = None) -> bool:
275
+ """Check if extended multizone is supported for given firmware version.
276
+
277
+ Args:
278
+ firmware_version: Firmware version to check (optional)
279
+
280
+ Returns:
281
+ True if extended multizone is supported
282
+ """
283
+ if not self.has_extended_multizone:
284
+ return False
285
+ if self.min_ext_mz_firmware is None:
286
+ return True
287
+ if firmware_version is None:
288
+ return True
289
+ return firmware_version >= self.min_ext_mz_firmware
290
+
291
+
292
+ '''
293
+
294
+ # Generate product definitions
295
+ products_code = generate_product_definitions(products_data)
296
+
297
+ # Generate helper functions
298
+ helper_functions = '''
299
+
300
+ class ProductRegistry:
301
+ """Registry of LIFX products and their capabilities."""
302
+
303
+ def __init__(self) -> None:
304
+ """Initialize product registry with pre-generated data."""
305
+ self._products = PRODUCTS.copy() # Copy to allow test overrides
306
+ self._loaded = True # Always loaded in generated registry
307
+
308
+ def load_from_dict(self, data: dict | list) -> None:
309
+ """Load products from parsed JSON data (for testing).
310
+
311
+ Args:
312
+ data: Parsed products.json dictionary or array
313
+ """
314
+ from typing import Any
315
+
316
+ # Clear existing products
317
+ self._products.clear()
318
+
319
+ # Handle both array and object formats
320
+ all_vendors = []
321
+ if isinstance(data, list):
322
+ all_vendors = data
323
+ else:
324
+ all_vendors = [data]
325
+
326
+ # Process each vendor
327
+ for vendor_data in all_vendors:
328
+ vendor_id = vendor_data.get("vid", 1)
329
+ defaults = vendor_data.get("defaults", {})
330
+ default_features = defaults.get("features", {})
331
+
332
+ # Parse each product
333
+ for product in vendor_data.get("products", []):
334
+ pid = product["pid"]
335
+ name = product["name"]
336
+
337
+ # Merge features with defaults
338
+ prod_features = product.get("features", {})
339
+ features: dict[str, Any] = {**default_features, **prod_features}
340
+
341
+ # Build capabilities bitfield
342
+ capabilities = 0
343
+ if features.get("color"):
344
+ capabilities |= ProductCapability.COLOR
345
+ if features.get("infrared"):
346
+ capabilities |= ProductCapability.INFRARED
347
+ if features.get("multizone"):
348
+ capabilities |= ProductCapability.MULTIZONE
349
+ if features.get("chain"):
350
+ capabilities |= ProductCapability.CHAIN
351
+ if features.get("matrix"):
352
+ capabilities |= ProductCapability.MATRIX
353
+ if features.get("relays"):
354
+ capabilities |= ProductCapability.RELAYS
355
+ if features.get("buttons"):
356
+ capabilities |= ProductCapability.BUTTONS
357
+ if features.get("hev"):
358
+ capabilities |= ProductCapability.HEV
359
+
360
+ # Check for extended multizone in upgrades
361
+ min_ext_mz_firmware = None
362
+ for upgrade in product.get("upgrades", []):
363
+ if upgrade.get("features", {}).get("extended_multizone"):
364
+ capabilities |= ProductCapability.EXTENDED_MULTIZONE
365
+ # Parse firmware version (major.minor format)
366
+ major = upgrade.get("major", 0)
367
+ minor = upgrade.get("minor", 0)
368
+ min_ext_mz_firmware = (major << 16) | minor
369
+ break
370
+
371
+ # Parse temperature range
372
+ temp_range = None
373
+ if "temperature_range" in features:
374
+ temp_list = features["temperature_range"]
375
+ if len(temp_list) >= 2:
376
+ temp_range = TemperatureRange(
377
+ min=temp_list[0], max=temp_list[1]
378
+ )
379
+
380
+ product_info = ProductInfo(
381
+ pid=pid,
382
+ name=name,
383
+ vendor=vendor_id,
384
+ capabilities=capabilities,
385
+ temperature_range=temp_range,
386
+ min_ext_mz_firmware=min_ext_mz_firmware,
387
+ )
388
+
389
+ self._products[pid] = product_info
390
+
391
+ self._loaded = True
392
+
393
+ @property
394
+ def is_loaded(self) -> bool:
395
+ """Check if registry has been loaded."""
396
+ return self._loaded
397
+
398
+ def get_product(self, pid: int) -> ProductInfo | None:
399
+ """Get product info by product ID.
400
+
401
+ Args:
402
+ pid: Product ID
403
+
404
+ Returns:
405
+ ProductInfo if found, None otherwise
406
+ """
407
+ return self._products.get(pid)
408
+
409
+ def get_device_class_name(
410
+ self, pid: int, firmware_version: int | None = None
411
+ ) -> str:
412
+ """Get appropriate device class name for a product.
413
+
414
+ Args:
415
+ pid: Product ID
416
+ firmware_version: Firmware version (optional)
417
+
418
+ Returns:
419
+ Device class name: "TileDevice", "MultiZoneLight", "HevLight",
420
+ "InfraredLight", "Light", or "Device"
421
+ """
422
+ product = self.get_product(pid)
423
+ if product is None:
424
+ # Unknown product - default to Light if we don't know
425
+ return "Light"
426
+
427
+ # Matrix devices (Tiles, Candles) → TileDevice
428
+ if product.has_matrix:
429
+ return "TileDevice"
430
+
431
+ # MultiZone devices (Strips, Beams) → MultiZoneLight
432
+ if product.has_multizone:
433
+ return "MultiZoneLight"
434
+
435
+ # HEV lights → HevLight
436
+ if product.has_hev:
437
+ return "HevLight"
438
+
439
+ # Infrared lights → InfraredLight
440
+ if product.has_infrared:
441
+ return "InfraredLight"
442
+
443
+ # Color lights → Light
444
+ if product.has_color:
445
+ return "Light"
446
+
447
+ # Devices with relays (switches/relays) → Device
448
+ if product.has_relays:
449
+ return "Device"
450
+
451
+ # Devices with buttons but no color (switches) → Device
452
+ if product.has_buttons:
453
+ return "Device"
454
+
455
+ # Everything else (basic lights, white-to-warm lights) → Light
456
+ # These have no special capabilities but still support Light protocol
457
+ return "Light"
458
+
459
+ def __len__(self) -> int:
460
+ """Get number of products in registry."""
461
+ return len(self._products)
462
+
463
+ def __contains__(self, pid: int) -> bool:
464
+ """Check if product ID exists in registry."""
465
+ return pid in self._products
466
+
467
+
468
+ # Global registry instance
469
+ _registry = ProductRegistry()
470
+
471
+
472
+ def get_registry() -> ProductRegistry:
473
+ """Get the global product registry.
474
+
475
+ Returns:
476
+ Global ProductRegistry instance
477
+ """
478
+ return _registry
479
+
480
+
481
+ def get_product(pid: int) -> ProductInfo | None:
482
+ """Get product info by product ID.
483
+
484
+ Args:
485
+ pid: Product ID
486
+
487
+ Returns:
488
+ ProductInfo if found, None otherwise
489
+ """
490
+ return _registry.get_product(pid)
491
+
492
+
493
+ def get_device_class_name(pid: int, firmware_version: int | None = None) -> str:
494
+ """Get appropriate device class name for a product.
495
+
496
+ Args:
497
+ pid: Product ID
498
+ firmware_version: Firmware version (optional)
499
+
500
+ Returns:
501
+ Device class name: "TileDevice", "MultiZoneLight", "Light", or "Device"
502
+ """
503
+ return _registry.get_device_class_name(pid, firmware_version)
504
+ '''
505
+
506
+ return header + products_code + helper_functions
507
+
508
+
509
+ def update_specs_file(
510
+ products_data: dict[str, Any] | list[dict[str, Any]], specs_path: Path
511
+ ) -> None:
512
+ """Update specs.yml with templates for new products and sort all entries by PID.
513
+
514
+ Args:
515
+ products_data: Parsed products.json data
516
+ specs_path: Path to specs.yml file
517
+ """
518
+ # Load existing specs
519
+ existing_specs = {}
520
+ if specs_path.exists():
521
+ with open(specs_path) as f:
522
+ specs_data = yaml.safe_load(f)
523
+ if specs_data and "products" in specs_data:
524
+ existing_specs = specs_data["products"]
525
+
526
+ # Extract all product IDs from products.json
527
+ all_product_ids = set()
528
+ all_vendors = []
529
+ if isinstance(products_data, list):
530
+ all_vendors = products_data
531
+ else:
532
+ all_vendors = [products_data]
533
+
534
+ # Collect all products with their features for template generation
535
+ new_products = []
536
+ for vendor_data in all_vendors:
537
+ defaults = vendor_data.get("defaults", {})
538
+ default_features = defaults.get("features", {})
539
+
540
+ for product in vendor_data.get("products", []):
541
+ pid = product["pid"]
542
+ all_product_ids.add(pid)
543
+
544
+ # Check if this product needs specs template
545
+ if pid not in existing_specs:
546
+ features = {**default_features, **product.get("features", {})}
547
+
548
+ # Determine if this product needs specs
549
+ is_multizone = features.get("multizone", False)
550
+ is_matrix = features.get("matrix", False)
551
+
552
+ if is_multizone or is_matrix:
553
+ new_products.append(
554
+ {
555
+ "pid": pid,
556
+ "name": product["name"],
557
+ "multizone": is_multizone,
558
+ "matrix": is_matrix,
559
+ "extended_multizone": False, # Will check upgrades
560
+ }
561
+ )
562
+
563
+ # Check for extended multizone in upgrades
564
+ for upgrade in product.get("upgrades", []):
565
+ if upgrade.get("features", {}).get("extended_multizone"):
566
+ new_products[-1]["extended_multizone"] = True
567
+ break
568
+
569
+ if not new_products:
570
+ print("No new multizone or matrix products found - specs.yml is up to date")
571
+ # Still need to sort existing entries
572
+ if existing_specs:
573
+ print("Sorting existing specs entries by product ID...")
574
+ else:
575
+ return
576
+ else:
577
+ print(f"\nFound {len(new_products)} new products that need specs:")
578
+ for product in new_products:
579
+ print(f" PID {product['pid']:>3}: {product['name']}")
580
+
581
+ # Add new products to existing specs with placeholder values
582
+ for product in new_products:
583
+ product_name = product["name"].replace('"', '\\"')
584
+
585
+ if product["multizone"]:
586
+ existing_specs[product["pid"]] = {
587
+ "default_zone_count": 16,
588
+ "min_zone_count": 1,
589
+ "max_zone_count": 16,
590
+ "notes": product_name,
591
+ }
592
+ elif product["matrix"]:
593
+ existing_specs[product["pid"]] = {
594
+ "default_tile_count": 1,
595
+ "min_tile_count": 1,
596
+ "max_tile_count": 1,
597
+ "tile_width": 8,
598
+ "tile_height": 8,
599
+ "notes": product_name,
600
+ }
601
+
602
+ # Now regenerate the entire file with sorted entries
603
+ # Separate multizone and matrix products
604
+ multizone_pids = []
605
+ matrix_pids = []
606
+
607
+ for pid, specs in existing_specs.items():
608
+ if "tile_width" in specs or "tile_height" in specs:
609
+ matrix_pids.append(pid)
610
+ elif "default_zone_count" in specs:
611
+ multizone_pids.append(pid)
612
+
613
+ multizone_pids.sort()
614
+ matrix_pids.sort()
615
+
616
+ # Build the new YAML content
617
+ lines = [
618
+ "# LIFX Product Specs and Defaults",
619
+ "# =================================",
620
+ "#",
621
+ "# This file contains product-specific details that are not available in the",
622
+ "# upstream LIFX products.json catalog, such as default zone counts, tile",
623
+ "# configurations, and other device-specific defaults.",
624
+ "#",
625
+ "# These values are used by the emulator to create realistic device",
626
+ "# configurations when specific parameters are not provided by the user.",
627
+ "#",
628
+ "# Format:",
629
+ "# -------",
630
+ "# products:",
631
+ "# <product_id>:",
632
+ "# # For multizone devices",
633
+ "# default_zone_count: <number> # Default zones (e.g., 16)",
634
+ "# min_zone_count: <number> # Minimum zones supported",
635
+ "# max_zone_count: <number> # Maximum zones supported",
636
+ "#",
637
+ "# # For matrix devices (tiles, candles, etc.)",
638
+ "# default_tile_count: <number> # Default number of tiles in chain",
639
+ "# min_tile_count: <number> # Minimum tiles supported",
640
+ "# max_tile_count: <number> # Maximum tiles supported",
641
+ "# tile_width: <number> # Width of each tile in pixels",
642
+ "# tile_height: <number> # Height of each tile in pixels",
643
+ "#",
644
+ "# # Other device-specific defaults",
645
+ '# notes: "<string>" # Notes about product',
646
+ "",
647
+ "products:",
648
+ ]
649
+
650
+ # Add multizone section
651
+ if multizone_pids:
652
+ lines.extend(
653
+ [
654
+ " # ========================================",
655
+ " # Multizone Products (Linear Strips)",
656
+ " # ========================================",
657
+ "",
658
+ ]
659
+ )
660
+
661
+ for pid in multizone_pids:
662
+ specs = existing_specs[pid]
663
+ # Get product name from comment or notes
664
+ name = specs.get("notes", f"Product {pid}").split(" - ")[0]
665
+
666
+ lines.append(f" {pid}: # {name}")
667
+ lines.append(f" default_zone_count: {specs['default_zone_count']}")
668
+ lines.append(f" min_zone_count: {specs['min_zone_count']}")
669
+ lines.append(f" max_zone_count: {specs['max_zone_count']}")
670
+
671
+ notes = specs.get("notes", "")
672
+ if notes:
673
+ # Escape quotes in notes
674
+ notes_escaped = notes.replace('"', '\\"')
675
+ lines.append(f' notes: "{notes_escaped}"')
676
+ lines.append("")
677
+
678
+ # Add matrix section
679
+ if matrix_pids:
680
+ lines.extend(
681
+ [
682
+ " # ========================================",
683
+ " # Matrix Products (Tiles, Candles, etc.)",
684
+ " # ========================================",
685
+ "",
686
+ ]
687
+ )
688
+
689
+ for pid in matrix_pids:
690
+ specs = existing_specs[pid]
691
+ # Get product name from notes
692
+ name = specs.get("notes", f"Product {pid}").split(" - ")[0]
693
+
694
+ lines.append(f" {pid}: # {name}")
695
+ lines.append(f" default_tile_count: {specs['default_tile_count']}")
696
+ lines.append(f" min_tile_count: {specs['min_tile_count']}")
697
+ lines.append(f" max_tile_count: {specs['max_tile_count']}")
698
+ lines.append(f" tile_width: {specs['tile_width']}")
699
+ lines.append(f" tile_height: {specs['tile_height']}")
700
+
701
+ notes = specs.get("notes", "")
702
+ if notes:
703
+ # Escape quotes in notes
704
+ notes_escaped = notes.replace('"', '\\"')
705
+ lines.append(f' notes: "{notes_escaped}"')
706
+ lines.append("")
707
+
708
+ # Write the new file
709
+ with open(specs_path, "w") as f:
710
+ f.write("\n".join(lines))
711
+
712
+ if new_products:
713
+ num_products = len(new_products)
714
+ print(
715
+ f"\n✓ Added {num_products} new product templates "
716
+ f"and sorted all entries by PID"
717
+ )
718
+ print(
719
+ " Please review and update the placeholder values "
720
+ "with actual product specifications"
721
+ )
722
+ else:
723
+ print(f"\n✓ Sorted all {len(existing_specs)} specs entries by product ID")
724
+
725
+
726
+ def main() -> None:
727
+ """Main generator entry point."""
728
+ try:
729
+ # Download and parse products from GitHub
730
+ products_data = download_products()
731
+ except Exception as e:
732
+ print(f"Error: Failed to download products.json: {e}", file=sys.stderr)
733
+ sys.exit(1)
734
+
735
+ # Count products for summary
736
+ if isinstance(products_data, list):
737
+ all_products = []
738
+ for vendor in products_data:
739
+ all_products.extend(vendor.get("products", []))
740
+ else:
741
+ all_products = products_data.get("products", [])
742
+
743
+ print(f"Found {len(all_products)} products")
744
+
745
+ # Generate registry.py
746
+ print("\nGenerating registry.py...")
747
+ registry_code = generate_registry_file(products_data)
748
+
749
+ # Determine output path
750
+ registry_path = Path(__file__).parent / "registry.py"
751
+
752
+ with open(registry_path, "w") as f:
753
+ f.write(registry_code)
754
+
755
+ print(f"✓ Generated {registry_path}")
756
+
757
+ # Update specs.yml with templates for new products
758
+ print("\nChecking for new products that need specs...")
759
+ specs_path = Path(__file__).parent / "specs.yml"
760
+
761
+ try:
762
+ update_specs_file(products_data, specs_path)
763
+ except Exception as e:
764
+ print(f"Warning: Failed to update specs.yml: {e}", file=sys.stderr)
765
+ print("You can manually add specs for new products")
766
+
767
+ print("\n✓ Generation complete!")
768
+
769
+
770
+ if __name__ == "__main__":
771
+ main()