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
@@ -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,84 +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")
191
+ skipped_count += 1
192
+ continue
99
193
 
100
- # Check for extended multizone capability
101
- min_ext_mz_firmware = None
194
+ # Build capabilities
195
+ capabilities = _build_capabilities(features)
102
196
 
103
- # First check if it's a native feature (no firmware requirement)
104
- if features.get("extended_multizone"):
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:
105
202
  capabilities.append("ProductCapability.EXTENDED_MULTIZONE")
106
- else:
107
- # Check if it's available as an upgrade (requires minimum firmware)
108
- for upgrade in product.get("upgrades", []):
109
- if upgrade.get("features", {}).get("extended_multizone"):
110
- capabilities.append("ProductCapability.EXTENDED_MULTIZONE")
111
- # Parse firmware version (major.minor format)
112
- major = upgrade.get("major", 0)
113
- minor = upgrade.get("minor", 0)
114
- min_ext_mz_firmware = (major << 16) | minor
115
- break
116
203
 
117
204
  # Build capabilities expression
118
- if capabilities:
119
- capabilities_expr = " | ".join(capabilities)
120
- else:
121
- capabilities_expr = "0"
122
-
123
- # Parse temperature range
124
- temp_range_expr = "None"
125
- if "temperature_range" in features:
126
- temp_list = features["temperature_range"]
127
- if len(temp_list) >= 2:
128
- temp_range_expr = (
129
- f"TemperatureRange(min={temp_list[0]}, max={temp_list[1]})"
130
- )
131
-
132
- # Format firmware version
133
- min_ext_mz_firmware_expr = (
134
- str(min_ext_mz_firmware) if min_ext_mz_firmware is not None else "None"
135
- )
136
-
137
- # Generate ProductInfo instantiation
138
- code_lines.append(f" {pid}: ProductInfo(")
139
- code_lines.append(f" pid={pid},")
140
- code_lines.append(f" name={repr(name)},")
141
- code_lines.append(f" vendor={vendor_id},")
142
- code_lines.append(f" capabilities={capabilities_expr},")
143
- code_lines.append(f" temperature_range={temp_range_expr},")
144
- code_lines.append(
145
- f" min_ext_mz_firmware={min_ext_mz_firmware_expr},"
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,
146
218
  )
147
- code_lines.append(" ),")
148
-
219
+ code_lines.extend(product_code)
149
220
  product_count += 1
150
221
 
151
222
  code_lines.append("}")
152
223
  code_lines.append("")
153
224
 
154
225
  print(f"Generated {product_count} product definitions")
226
+ if skipped_count > 0:
227
+ print(f"Skipped {skipped_count} switch products (relays only)")
155
228
  return "\n".join(code_lines)
156
229
 
157
230
 
@@ -177,6 +250,7 @@ from __future__ import annotations
177
250
 
178
251
  from dataclasses import dataclass
179
252
  from enum import IntEnum
253
+ from functools import cached_property
180
254
 
181
255
 
182
256
  class ProductCapability(IntEnum):
@@ -294,6 +368,53 @@ class ProductInfo:
294
368
  return True
295
369
  return firmware_version >= self.min_ext_mz_firmware
296
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
+
297
418
 
298
419
  '''
299
420
 
@@ -344,6 +465,10 @@ class ProductRegistry:
344
465
  prod_features = product.get("features", {})
345
466
  features: dict[str, Any] = {**default_features, **prod_features}
346
467
 
468
+ # Skip switch products (devices with relays) - these are not lights
469
+ if features.get("relays"):
470
+ continue
471
+
347
472
  # Build capabilities bitfield
348
473
  capabilities = 0
349
474
  if features.get("color"):
@@ -518,32 +643,45 @@ def get_device_class_name(pid: int, firmware_version: int | None = None) -> str:
518
643
  return header + products_code + helper_functions
519
644
 
520
645
 
521
- def update_specs_file(
522
- products_data: dict[str, Any] | list[dict[str, Any]], specs_path: Path
523
- ) -> None:
524
- """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.
525
648
 
526
649
  Args:
527
- products_data: Parsed products.json data
528
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
529
678
  """
530
- # Load existing specs
531
- existing_specs = {}
532
- if specs_path.exists():
533
- with open(specs_path) as f:
534
- specs_data = yaml.safe_load(f)
535
- if specs_data and "products" in specs_data:
536
- existing_specs = specs_data["products"]
537
-
538
- # Extract all product IDs from products.json
539
- all_product_ids = set()
540
679
  all_vendors = []
541
680
  if isinstance(products_data, list):
542
681
  all_vendors = products_data
543
682
  else:
544
683
  all_vendors = [products_data]
545
684
 
546
- # Collect all products with their features for template generation
547
685
  new_products = []
548
686
  for vendor_data in all_vendors:
549
687
  defaults = vendor_data.get("defaults", {})
@@ -551,46 +689,46 @@ def update_specs_file(
551
689
 
552
690
  for product in vendor_data.get("products", []):
553
691
  pid = product["pid"]
554
- 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
555
697
 
556
698
  # Check if this product needs specs template
557
699
  if pid not in existing_specs:
558
- features = {**default_features, **product.get("features", {})}
559
-
560
- # Determine if this product needs specs
561
700
  is_multizone = features.get("multizone", False)
562
701
  is_matrix = features.get("matrix", False)
563
702
 
564
703
  if is_multizone or is_matrix:
565
- new_products.append(
566
- {
567
- "pid": pid,
568
- "name": product["name"],
569
- "multizone": is_multizone,
570
- "matrix": is_matrix,
571
- "extended_multizone": False, # Will check upgrades
572
- }
573
- )
704
+ new_product = {
705
+ "pid": pid,
706
+ "name": product["name"],
707
+ "multizone": is_multizone,
708
+ "matrix": is_matrix,
709
+ "extended_multizone": False,
710
+ }
574
711
 
575
712
  # Check for extended multizone in upgrades
576
713
  for upgrade in product.get("upgrades", []):
577
714
  if upgrade.get("features", {}).get("extended_multizone"):
578
- new_products[-1]["extended_multizone"] = True
715
+ new_product["extended_multizone"] = True
579
716
  break
580
717
 
581
- if not new_products:
582
- print("No new multizone or matrix products found - specs.yml is up to date")
583
- # Still need to sort existing entries
584
- if existing_specs:
585
- print("Sorting existing specs entries by product ID...")
586
- else:
587
- return
588
- else:
589
- print(f"\nFound {len(new_products)} new products that need specs:")
590
- for product in new_products:
591
- print(f" PID {product['pid']:>3}: {product['name']}")
718
+ new_products.append(new_product)
719
+
720
+ return new_products
592
721
 
593
- # Add new products to existing specs with placeholder values
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
+ """
594
732
  for product in new_products:
595
733
  product_name = product["name"].replace('"', '\\"')
596
734
 
@@ -611,8 +749,18 @@ def update_specs_file(
611
749
  "notes": product_name,
612
750
  }
613
751
 
614
- # Now regenerate the entire file with sorted entries
615
- # 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
+ """
616
764
  multizone_pids = []
617
765
  matrix_pids = []
618
766
 
@@ -625,8 +773,16 @@ def update_specs_file(
625
773
  multizone_pids.sort()
626
774
  matrix_pids.sort()
627
775
 
628
- # Build the new YAML content
629
- 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 [
630
786
  "# LIFX Product Specs and Defaults",
631
787
  "# =================================",
632
788
  "#",
@@ -659,72 +815,135 @@ def update_specs_file(
659
815
  "products:",
660
816
  ]
661
817
 
662
- # Add multizone section
663
- if multizone_pids:
664
- lines.extend(
665
- [
666
- " # ========================================",
667
- " # Multizone Products (Linear Strips)",
668
- " # ========================================",
669
- "",
670
- ]
671
- )
672
818
 
673
- for pid in multizone_pids:
674
- specs = existing_specs[pid]
675
- # Get product name from comment or notes
676
- name = specs.get("notes", f"Product {pid}").split(" - ")[0]
677
-
678
- lines.append(f" {pid}: # {name}")
679
- lines.append(f" default_zone_count: {specs['default_zone_count']}")
680
- lines.append(f" min_zone_count: {specs['min_zone_count']}")
681
- lines.append(f" max_zone_count: {specs['max_zone_count']}")
682
-
683
- notes = specs.get("notes", "")
684
- if notes:
685
- # Escape quotes in notes
686
- notes_escaped = notes.replace('"', '\\"')
687
- lines.append(f' notes: "{notes_escaped}"')
688
- lines.append("")
689
-
690
- # Add matrix section
691
- if matrix_pids:
692
- lines.extend(
693
- [
694
- " # ========================================",
695
- " # Matrix Products (Tiles, Candles, etc.)",
696
- " # ========================================",
697
- "",
698
- ]
699
- )
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)
700
933
 
701
- for pid in matrix_pids:
702
- specs = existing_specs[pid]
703
- # Get product name from notes
704
- name = specs.get("notes", f"Product {pid}").split(" - ")[0]
705
-
706
- lines.append(f" {pid}: # {name}")
707
- lines.append(f" default_tile_count: {specs['default_tile_count']}")
708
- lines.append(f" min_tile_count: {specs['min_tile_count']}")
709
- lines.append(f" max_tile_count: {specs['max_tile_count']}")
710
- lines.append(f" tile_width: {specs['tile_width']}")
711
- lines.append(f" tile_height: {specs['tile_height']}")
712
-
713
- notes = specs.get("notes", "")
714
- if notes:
715
- # Escape quotes in notes
716
- notes_escaped = notes.replace('"', '\\"')
717
- lines.append(f' notes: "{notes_escaped}"')
718
- 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))
719
938
 
720
939
  # Write the new file
721
940
  with open(specs_path, "w") as f:
722
941
  f.write("\n".join(lines))
723
942
 
943
+ # Print completion message
724
944
  if new_products:
725
- num_products = len(new_products)
726
945
  print(
727
- f"\n✓ Added {num_products} new product templates "
946
+ f"\n✓ Added {len(new_products)} new product templates "
728
947
  f"and sorted all entries by PID"
729
948
  )
730
949
  print(