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.
- lifx_emulator/__init__.py +1 -1
- lifx_emulator/__main__.py +26 -51
- lifx_emulator/api/__init__.py +18 -0
- lifx_emulator/api/app.py +154 -0
- lifx_emulator/api/mappers/__init__.py +5 -0
- lifx_emulator/api/mappers/device_mapper.py +114 -0
- lifx_emulator/api/models.py +133 -0
- lifx_emulator/api/routers/__init__.py +11 -0
- lifx_emulator/api/routers/devices.py +130 -0
- lifx_emulator/api/routers/monitoring.py +52 -0
- lifx_emulator/api/routers/scenarios.py +247 -0
- lifx_emulator/api/services/__init__.py +8 -0
- lifx_emulator/api/services/device_service.py +198 -0
- lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
- lifx_emulator/devices/__init__.py +37 -0
- lifx_emulator/devices/device.py +333 -0
- lifx_emulator/devices/manager.py +256 -0
- lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
- lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
- lifx_emulator/devices/states.py +346 -0
- lifx_emulator/factories/__init__.py +37 -0
- lifx_emulator/factories/builder.py +371 -0
- lifx_emulator/factories/default_config.py +158 -0
- lifx_emulator/factories/factory.py +221 -0
- lifx_emulator/factories/firmware_config.py +59 -0
- lifx_emulator/factories/serial_generator.py +82 -0
- lifx_emulator/handlers/base.py +1 -1
- lifx_emulator/handlers/device_handlers.py +10 -28
- lifx_emulator/handlers/light_handlers.py +5 -9
- lifx_emulator/handlers/multizone_handlers.py +1 -1
- lifx_emulator/handlers/tile_handlers.py +31 -11
- lifx_emulator/products/generator.py +389 -170
- lifx_emulator/products/registry.py +52 -40
- lifx_emulator/products/specs.py +12 -13
- lifx_emulator/protocol/base.py +175 -63
- lifx_emulator/protocol/generator.py +18 -5
- lifx_emulator/protocol/packets.py +7 -7
- lifx_emulator/protocol/protocol_types.py +35 -62
- lifx_emulator/repositories/__init__.py +22 -0
- lifx_emulator/repositories/device_repository.py +155 -0
- lifx_emulator/repositories/storage_backend.py +107 -0
- lifx_emulator/scenarios/__init__.py +22 -0
- lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
- lifx_emulator/scenarios/models.py +112 -0
- lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
- lifx_emulator/server.py +42 -66
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/METADATA +1 -1
- lifx_emulator-2.1.0.dist-info/RECORD +62 -0
- lifx_emulator/device.py +0 -750
- lifx_emulator/device_states.py +0 -114
- lifx_emulator/factories.py +0 -380
- lifx_emulator/storage_protocol.py +0 -100
- lifx_emulator-1.0.2.dist-info/RECORD +0 -40
- /lifx_emulator/{observers.py → devices/observers.py} +0 -0
- /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
#
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
capabilities.append("ProductCapability.BUTTONS")
|
|
97
|
-
if features.get("hev"):
|
|
98
|
-
capabilities.append("ProductCapability.HEV")
|
|
191
|
+
skipped_count += 1
|
|
192
|
+
continue
|
|
99
193
|
|
|
100
|
-
#
|
|
101
|
-
|
|
194
|
+
# Build capabilities
|
|
195
|
+
capabilities = _build_capabilities(features)
|
|
102
196
|
|
|
103
|
-
#
|
|
104
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
715
|
+
new_product["extended_multizone"] = True
|
|
579
716
|
break
|
|
580
717
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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 {
|
|
946
|
+
f"\n✓ Added {len(new_products)} new product templates "
|
|
728
947
|
f"and sorted all entries by PID"
|
|
729
948
|
)
|
|
730
949
|
print(
|