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.
- lifx_emulator/__init__.py +1 -1
- lifx_emulator/__main__.py +26 -49
- 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 +333 -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 +1 -1
- lifx_emulator/products/generator.py +406 -175
- lifx_emulator/products/registry.py +115 -65
- lifx_emulator/products/specs.py +12 -13
- lifx_emulator/protocol/base.py +115 -61
- lifx_emulator/protocol/generator.py +18 -5
- lifx_emulator/protocol/packets.py +7 -7
- 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 +38 -64
- {lifx_emulator-1.0.1.dist-info → lifx_emulator-2.0.0.dist-info}/METADATA +1 -1
- lifx_emulator-2.0.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.1.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.1.dist-info → lifx_emulator-2.0.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-1.0.1.dist-info → lifx_emulator-2.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
#
|
|
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")
|
|
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
|
|
112
|
-
|
|
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
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
491
|
+
# Check for extended multizone capability
|
|
361
492
|
min_ext_mz_firmware = None
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
715
|
+
new_product["extended_multizone"] = True
|
|
567
716
|
break
|
|
568
717
|
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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 {
|
|
946
|
+
f"\n✓ Added {len(new_products)} new product templates "
|
|
716
947
|
f"and sorted all entries by PID"
|
|
717
948
|
)
|
|
718
949
|
print(
|