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
@@ -17,6 +17,119 @@ import yaml
17
17
  from lifx_emulator.constants import PRODUCTS_URL
18
18
 
19
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
+
20
133
  def download_products() -> dict[str, Any] | list[dict[str, Any]]:
21
134
  """Download and parse products.json from LIFX GitHub repository.
22
135
 
@@ -52,10 +165,8 @@ def generate_product_definitions(
52
165
  # Handle both array and object formats
53
166
  all_vendors = []
54
167
  if isinstance(products_data, list):
55
- # Array format - multiple vendors
56
168
  all_vendors = products_data
57
169
  else:
58
- # Object format - single vendor
59
170
  all_vendors = [products_data]
60
171
 
61
172
  # Generate product definitions
@@ -63,10 +174,9 @@ def generate_product_definitions(
63
174
  code_lines.append("PRODUCTS: dict[int, ProductInfo] = {")
64
175
 
65
176
  product_count = 0
177
+ skipped_count = 0
66
178
  for vendor_data in all_vendors:
67
179
  vendor_id = vendor_data.get("vid", 1)
68
-
69
- # Get default features
70
180
  defaults = vendor_data.get("defaults", {})
71
181
  default_features = defaults.get("features", {})
72
182
 
@@ -74,78 +184,47 @@ def generate_product_definitions(
74
184
  for product in vendor_data.get("products", []):
75
185
  pid = product["pid"]
76
186
  name = product["name"]
77
-
78
- # Merge features with defaults
79
187
  features = {**default_features, **product.get("features", {})}
80
188
 
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")
189
+ # Skip switch products (devices with relays) - these are not lights
93
190
  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
191
+ skipped_count += 1
192
+ continue
110
193
 
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
- )
194
+ # Build capabilities
195
+ capabilities = _build_capabilities(features)
130
196
 
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},"
197
+ # Check for extended multizone
198
+ has_ext_mz, min_ext_mz_firmware = _check_extended_multizone(
199
+ product, features
140
200
  )
141
- code_lines.append(" ),")
201
+ if has_ext_mz:
202
+ capabilities.append("ProductCapability.EXTENDED_MULTIZONE")
142
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)
143
220
  product_count += 1
144
221
 
145
222
  code_lines.append("}")
146
223
  code_lines.append("")
147
224
 
148
225
  print(f"Generated {product_count} product definitions")
226
+ if skipped_count > 0:
227
+ print(f"Skipped {skipped_count} switch products (relays only)")
149
228
  return "\n".join(code_lines)
150
229
 
151
230
 
@@ -171,6 +250,7 @@ from __future__ import annotations
171
250
 
172
251
  from dataclasses import dataclass
173
252
  from enum import IntEnum
253
+ from functools import cached_property
174
254
 
175
255
 
176
256
  class ProductCapability(IntEnum):
@@ -288,6 +368,53 @@ class ProductInfo:
288
368
  return True
289
369
  return firmware_version >= self.min_ext_mz_firmware
290
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
+
291
418
 
292
419
  '''
293
420
 
@@ -338,6 +465,10 @@ class ProductRegistry:
338
465
  prod_features = product.get("features", {})
339
466
  features: dict[str, Any] = {**default_features, **prod_features}
340
467
 
468
+ # Skip switch products (devices with relays) - these are not lights
469
+ if features.get("relays"):
470
+ continue
471
+
341
472
  # Build capabilities bitfield
342
473
  capabilities = 0
343
474
  if features.get("color"):
@@ -357,16 +488,22 @@ class ProductRegistry:
357
488
  if features.get("hev"):
358
489
  capabilities |= ProductCapability.HEV
359
490
 
360
- # Check for extended multizone in upgrades
491
+ # Check for extended multizone capability
361
492
  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
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
370
507
 
371
508
  # Parse temperature range
372
509
  temp_range = None
@@ -506,32 +643,45 @@ def get_device_class_name(pid: int, firmware_version: int | None = None) -> str:
506
643
  return header + products_code + helper_functions
507
644
 
508
645
 
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.
646
+ def _load_existing_specs(specs_path: Path) -> dict[int, dict[str, Any]]:
647
+ """Load existing specs from YAML file.
513
648
 
514
649
  Args:
515
- products_data: Parsed products.json data
516
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
517
678
  """
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
679
  all_vendors = []
529
680
  if isinstance(products_data, list):
530
681
  all_vendors = products_data
531
682
  else:
532
683
  all_vendors = [products_data]
533
684
 
534
- # Collect all products with their features for template generation
535
685
  new_products = []
536
686
  for vendor_data in all_vendors:
537
687
  defaults = vendor_data.get("defaults", {})
@@ -539,46 +689,46 @@ def update_specs_file(
539
689
 
540
690
  for product in vendor_data.get("products", []):
541
691
  pid = product["pid"]
542
- all_product_ids.add(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
543
697
 
544
698
  # Check if this product needs specs template
545
699
  if pid not in existing_specs:
546
- features = {**default_features, **product.get("features", {})}
547
-
548
- # Determine if this product needs specs
549
700
  is_multizone = features.get("multizone", False)
550
701
  is_matrix = features.get("matrix", False)
551
702
 
552
703
  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
- )
704
+ new_product = {
705
+ "pid": pid,
706
+ "name": product["name"],
707
+ "multizone": is_multizone,
708
+ "matrix": is_matrix,
709
+ "extended_multizone": False,
710
+ }
562
711
 
563
712
  # Check for extended multizone in upgrades
564
713
  for upgrade in product.get("upgrades", []):
565
714
  if upgrade.get("features", {}).get("extended_multizone"):
566
- new_products[-1]["extended_multizone"] = True
715
+ new_product["extended_multizone"] = True
567
716
  break
568
717
 
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']}")
718
+ new_products.append(new_product)
580
719
 
581
- # Add new products to existing specs with placeholder values
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
+ """
582
732
  for product in new_products:
583
733
  product_name = product["name"].replace('"', '\\"')
584
734
 
@@ -599,8 +749,18 @@ def update_specs_file(
599
749
  "notes": product_name,
600
750
  }
601
751
 
602
- # Now regenerate the entire file with sorted entries
603
- # Separate multizone and matrix products
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
+ """
604
764
  multizone_pids = []
605
765
  matrix_pids = []
606
766
 
@@ -613,8 +773,16 @@ def update_specs_file(
613
773
  multizone_pids.sort()
614
774
  matrix_pids.sort()
615
775
 
616
- # Build the new YAML content
617
- lines = [
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 [
618
786
  "# LIFX Product Specs and Defaults",
619
787
  "# =================================",
620
788
  "#",
@@ -647,72 +815,135 @@ def update_specs_file(
647
815
  "products:",
648
816
  ]
649
817
 
650
- # Add multizone section
651
- if multizone_pids:
652
- lines.extend(
653
- [
654
- " # ========================================",
655
- " # Multizone Products (Linear Strips)",
656
- " # ========================================",
657
- "",
658
- ]
659
- )
660
818
 
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
- )
819
+ def _generate_multizone_section(
820
+ multizone_pids: list[int], existing_specs: dict[int, dict[str, Any]]
821
+ ) -> list[str]:
822
+ """Generate YAML lines for multizone products section.
823
+
824
+ Args:
825
+ multizone_pids: Sorted list of multizone product IDs
826
+ existing_specs: Product specs dictionary
827
+
828
+ Returns:
829
+ List of YAML lines
830
+ """
831
+ if not multizone_pids:
832
+ return []
833
+
834
+ lines = [
835
+ " # ========================================",
836
+ " # Multizone Products (Linear Strips)",
837
+ " # ========================================",
838
+ "",
839
+ ]
840
+
841
+ for pid in multizone_pids:
842
+ specs = existing_specs[pid]
843
+ name = specs.get("notes", f"Product {pid}").split(" - ")[0]
844
+
845
+ lines.append(f" {pid}: # {name}")
846
+ lines.append(f" default_zone_count: {specs['default_zone_count']}")
847
+ lines.append(f" min_zone_count: {specs['min_zone_count']}")
848
+ lines.append(f" max_zone_count: {specs['max_zone_count']}")
849
+
850
+ notes = specs.get("notes", "")
851
+ if notes:
852
+ notes_escaped = notes.replace('"', '\\"')
853
+ lines.append(f' notes: "{notes_escaped}"')
854
+ lines.append("")
855
+
856
+ return lines
857
+
858
+
859
+ def _generate_matrix_section(
860
+ matrix_pids: list[int], existing_specs: dict[int, dict[str, Any]]
861
+ ) -> list[str]:
862
+ """Generate YAML lines for matrix products section.
863
+
864
+ Args:
865
+ matrix_pids: Sorted list of matrix product IDs
866
+ existing_specs: Product specs dictionary
867
+
868
+ Returns:
869
+ List of YAML lines
870
+ """
871
+ if not matrix_pids:
872
+ return []
873
+
874
+ lines = [
875
+ " # ========================================",
876
+ " # Matrix Products (Tiles, Candles, etc.)",
877
+ " # ========================================",
878
+ "",
879
+ ]
880
+
881
+ for pid in matrix_pids:
882
+ specs = existing_specs[pid]
883
+ name = specs.get("notes", f"Product {pid}").split(" - ")[0]
884
+
885
+ lines.append(f" {pid}: # {name}")
886
+ lines.append(f" default_tile_count: {specs['default_tile_count']}")
887
+ lines.append(f" min_tile_count: {specs['min_tile_count']}")
888
+ lines.append(f" max_tile_count: {specs['max_tile_count']}")
889
+ lines.append(f" tile_width: {specs['tile_width']}")
890
+ lines.append(f" tile_height: {specs['tile_height']}")
891
+
892
+ notes = specs.get("notes", "")
893
+ if notes:
894
+ notes_escaped = notes.replace('"', '\\"')
895
+ lines.append(f' notes: "{notes_escaped}"')
896
+ lines.append("")
897
+
898
+ return lines
899
+
900
+
901
+ def update_specs_file(
902
+ products_data: dict[str, Any] | list[dict[str, Any]], specs_path: Path
903
+ ) -> None:
904
+ """Update specs.yml with templates for new products and sort all entries by PID.
905
+
906
+ Args:
907
+ products_data: Parsed products.json data
908
+ specs_path: Path to specs.yml file
909
+ """
910
+ # Load existing specs
911
+ existing_specs = _load_existing_specs(specs_path)
912
+
913
+ # Find new products that need specs
914
+ new_products = _discover_new_products(products_data, existing_specs)
915
+
916
+ # Print status
917
+ if not new_products:
918
+ print("No new multizone or matrix products found - specs.yml is up to date")
919
+ if existing_specs:
920
+ print("Sorting existing specs entries by product ID...")
921
+ else:
922
+ return
923
+ else:
924
+ print(f"\nFound {len(new_products)} new products that need specs:")
925
+ for product in new_products:
926
+ print(f" PID {product['pid']:>3}: {product['name']}")
927
+
928
+ # Add templates for new products
929
+ _add_product_templates(new_products, existing_specs)
930
+
931
+ # Categorize products and sort
932
+ multizone_pids, matrix_pids = _categorize_products(existing_specs)
688
933
 
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("")
934
+ # Build YAML content
935
+ lines = _generate_yaml_header()
936
+ lines.extend(_generate_multizone_section(multizone_pids, existing_specs))
937
+ lines.extend(_generate_matrix_section(matrix_pids, existing_specs))
707
938
 
708
939
  # Write the new file
709
940
  with open(specs_path, "w") as f:
710
941
  f.write("\n".join(lines))
711
942
 
943
+ # Print completion message
712
944
  if new_products:
713
- num_products = len(new_products)
714
945
  print(
715
- f"\n✓ Added {num_products} new product templates "
946
+ f"\n✓ Added {len(new_products)} new product templates "
716
947
  f"and sorted all entries by PID"
717
948
  )
718
949
  print(