lifx-emulator 2.4.0__py3-none-any.whl → 3.0.1__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-3.0.1.dist-info/METADATA +102 -0
- lifx_emulator-3.0.1.dist-info/RECORD +18 -0
- lifx_emulator-3.0.1.dist-info/entry_points.txt +2 -0
- lifx_emulator_app/__init__.py +10 -0
- {lifx_emulator → lifx_emulator_app}/__main__.py +2 -3
- {lifx_emulator → lifx_emulator_app}/api/__init__.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/app.py +3 -3
- {lifx_emulator → lifx_emulator_app}/api/mappers/__init__.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/mappers/device_mapper.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/models.py +1 -2
- lifx_emulator_app/api/routers/__init__.py +11 -0
- {lifx_emulator → lifx_emulator_app}/api/routers/devices.py +2 -2
- {lifx_emulator → lifx_emulator_app}/api/routers/monitoring.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/routers/scenarios.py +1 -1
- lifx_emulator_app/api/services/__init__.py +8 -0
- {lifx_emulator → lifx_emulator_app}/api/services/device_service.py +3 -2
- lifx_emulator/__init__.py +0 -31
- lifx_emulator/api/routers/__init__.py +0 -11
- lifx_emulator/api/services/__init__.py +0 -8
- lifx_emulator/constants.py +0 -33
- lifx_emulator/devices/__init__.py +0 -37
- lifx_emulator/devices/device.py +0 -395
- lifx_emulator/devices/manager.py +0 -256
- lifx_emulator/devices/observers.py +0 -139
- lifx_emulator/devices/persistence.py +0 -308
- lifx_emulator/devices/state_restorer.py +0 -259
- lifx_emulator/devices/state_serializer.py +0 -157
- lifx_emulator/devices/states.py +0 -381
- lifx_emulator/factories/__init__.py +0 -39
- lifx_emulator/factories/builder.py +0 -375
- lifx_emulator/factories/default_config.py +0 -158
- lifx_emulator/factories/factory.py +0 -252
- lifx_emulator/factories/firmware_config.py +0 -77
- lifx_emulator/factories/serial_generator.py +0 -82
- lifx_emulator/handlers/__init__.py +0 -39
- lifx_emulator/handlers/base.py +0 -49
- lifx_emulator/handlers/device_handlers.py +0 -322
- lifx_emulator/handlers/light_handlers.py +0 -503
- lifx_emulator/handlers/multizone_handlers.py +0 -249
- lifx_emulator/handlers/registry.py +0 -110
- lifx_emulator/handlers/tile_handlers.py +0 -488
- lifx_emulator/products/__init__.py +0 -28
- lifx_emulator/products/generator.py +0 -1079
- lifx_emulator/products/registry.py +0 -1530
- lifx_emulator/products/specs.py +0 -284
- lifx_emulator/products/specs.yml +0 -386
- lifx_emulator/protocol/__init__.py +0 -1
- lifx_emulator/protocol/base.py +0 -446
- lifx_emulator/protocol/const.py +0 -8
- lifx_emulator/protocol/generator.py +0 -1384
- lifx_emulator/protocol/header.py +0 -159
- lifx_emulator/protocol/packets.py +0 -1351
- lifx_emulator/protocol/protocol_types.py +0 -817
- lifx_emulator/protocol/serializer.py +0 -379
- lifx_emulator/repositories/__init__.py +0 -22
- lifx_emulator/repositories/device_repository.py +0 -155
- lifx_emulator/repositories/storage_backend.py +0 -107
- lifx_emulator/scenarios/__init__.py +0 -22
- lifx_emulator/scenarios/manager.py +0 -322
- lifx_emulator/scenarios/models.py +0 -112
- lifx_emulator/scenarios/persistence.py +0 -241
- lifx_emulator/server.py +0 -464
- lifx_emulator-2.4.0.dist-info/METADATA +0 -107
- lifx_emulator-2.4.0.dist-info/RECORD +0 -62
- lifx_emulator-2.4.0.dist-info/entry_points.txt +0 -2
- lifx_emulator-2.4.0.dist-info/licenses/LICENSE +0 -35
- {lifx_emulator-2.4.0.dist-info → lifx_emulator-3.0.1.dist-info}/WHEEL +0 -0
- {lifx_emulator → lifx_emulator_app}/api/templates/dashboard.html +0 -0
|
@@ -1,1079 +0,0 @@
|
|
|
1
|
-
"""Code generator for LIFX product registry.
|
|
2
|
-
|
|
3
|
-
Downloads the official products.json from the LIFX GitHub repository and
|
|
4
|
-
generates optimized Python code with pre-built product definitions.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import sys
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
13
|
-
from urllib.request import urlopen
|
|
14
|
-
|
|
15
|
-
import yaml
|
|
16
|
-
|
|
17
|
-
from lifx_emulator.constants import PRODUCTS_URL
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _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
|
-
|
|
133
|
-
def download_products() -> dict[str, Any] | list[dict[str, Any]]:
|
|
134
|
-
"""Download and parse products.json from LIFX GitHub repository.
|
|
135
|
-
|
|
136
|
-
Returns:
|
|
137
|
-
Parsed products dictionary or list
|
|
138
|
-
|
|
139
|
-
Raises:
|
|
140
|
-
URLError: If download fails
|
|
141
|
-
json.JSONDecodeError: If parsing fails
|
|
142
|
-
"""
|
|
143
|
-
print(f"Downloading products.json from {PRODUCTS_URL}...")
|
|
144
|
-
with urlopen(PRODUCTS_URL) as response: # nosec
|
|
145
|
-
products_data = response.read()
|
|
146
|
-
|
|
147
|
-
print("Parsing products specification...")
|
|
148
|
-
products = json.loads(products_data)
|
|
149
|
-
return products
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def generate_product_definitions(
|
|
153
|
-
products_data: dict[str, Any] | list[dict[str, Any]],
|
|
154
|
-
) -> str:
|
|
155
|
-
"""Generate Python code for product definitions.
|
|
156
|
-
|
|
157
|
-
Args:
|
|
158
|
-
products_data: Parsed products.json data
|
|
159
|
-
|
|
160
|
-
Returns:
|
|
161
|
-
Python code string with ProductInfo instances
|
|
162
|
-
"""
|
|
163
|
-
code_lines = []
|
|
164
|
-
|
|
165
|
-
# Handle both array and object formats
|
|
166
|
-
all_vendors = []
|
|
167
|
-
if isinstance(products_data, list):
|
|
168
|
-
all_vendors = products_data
|
|
169
|
-
else:
|
|
170
|
-
all_vendors = [products_data]
|
|
171
|
-
|
|
172
|
-
# Generate product definitions
|
|
173
|
-
code_lines.append("# Pre-generated product definitions")
|
|
174
|
-
code_lines.append("PRODUCTS: dict[int, ProductInfo] = {")
|
|
175
|
-
|
|
176
|
-
product_count = 0
|
|
177
|
-
for vendor_data in all_vendors:
|
|
178
|
-
vendor_id = vendor_data.get("vid", 1)
|
|
179
|
-
defaults = vendor_data.get("defaults", {})
|
|
180
|
-
default_features = defaults.get("features", {})
|
|
181
|
-
|
|
182
|
-
# Process each product
|
|
183
|
-
for product in vendor_data.get("products", []):
|
|
184
|
-
pid = product["pid"]
|
|
185
|
-
name = product["name"]
|
|
186
|
-
features = {**default_features, **product.get("features", {})}
|
|
187
|
-
|
|
188
|
-
# Build capabilities
|
|
189
|
-
capabilities = _build_capabilities(features)
|
|
190
|
-
|
|
191
|
-
# Check for extended multizone
|
|
192
|
-
has_ext_mz, min_ext_mz_firmware = _check_extended_multizone(
|
|
193
|
-
product, features
|
|
194
|
-
)
|
|
195
|
-
if has_ext_mz:
|
|
196
|
-
capabilities.append("ProductCapability.EXTENDED_MULTIZONE")
|
|
197
|
-
|
|
198
|
-
# Build capabilities expression
|
|
199
|
-
capabilities_expr = " | ".join(capabilities) if capabilities else "0"
|
|
200
|
-
|
|
201
|
-
# Format temperature range
|
|
202
|
-
temp_range_expr = _format_temperature_range(features)
|
|
203
|
-
|
|
204
|
-
# Generate code for this product
|
|
205
|
-
product_code = _generate_product_code(
|
|
206
|
-
pid,
|
|
207
|
-
name,
|
|
208
|
-
vendor_id,
|
|
209
|
-
capabilities_expr,
|
|
210
|
-
temp_range_expr,
|
|
211
|
-
min_ext_mz_firmware,
|
|
212
|
-
)
|
|
213
|
-
code_lines.extend(product_code)
|
|
214
|
-
product_count += 1
|
|
215
|
-
|
|
216
|
-
code_lines.append("}")
|
|
217
|
-
code_lines.append("")
|
|
218
|
-
|
|
219
|
-
print(f"Generated {product_count} product definitions")
|
|
220
|
-
return "\n".join(code_lines)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def generate_registry_file(products_data: dict[str, Any] | list[dict[str, Any]]) -> str:
|
|
224
|
-
"""Generate complete registry.py file.
|
|
225
|
-
|
|
226
|
-
Args:
|
|
227
|
-
products_data: Parsed products.json data
|
|
228
|
-
|
|
229
|
-
Returns:
|
|
230
|
-
Complete Python file content
|
|
231
|
-
"""
|
|
232
|
-
header = '''"""LIFX product definitions and capability detection.
|
|
233
|
-
|
|
234
|
-
DO NOT EDIT THIS FILE MANUALLY.
|
|
235
|
-
Generated from https://github.com/LIFX/products/blob/master/products.json
|
|
236
|
-
by products/generator.py
|
|
237
|
-
|
|
238
|
-
This module provides pre-generated product information for efficient runtime lookups.
|
|
239
|
-
"""
|
|
240
|
-
|
|
241
|
-
from __future__ import annotations
|
|
242
|
-
|
|
243
|
-
from dataclasses import dataclass
|
|
244
|
-
from enum import IntEnum
|
|
245
|
-
from functools import cached_property
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
class ProductCapability(IntEnum):
|
|
249
|
-
"""Product capability flags."""
|
|
250
|
-
|
|
251
|
-
COLOR = 1
|
|
252
|
-
INFRARED = 2
|
|
253
|
-
MULTIZONE = 4
|
|
254
|
-
CHAIN = 8
|
|
255
|
-
MATRIX = 16
|
|
256
|
-
RELAYS = 32
|
|
257
|
-
BUTTONS = 64
|
|
258
|
-
HEV = 128
|
|
259
|
-
EXTENDED_MULTIZONE = 256
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
@dataclass
|
|
263
|
-
class TemperatureRange:
|
|
264
|
-
"""Color temperature range in Kelvin."""
|
|
265
|
-
|
|
266
|
-
min: int
|
|
267
|
-
max: int
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
@dataclass
|
|
271
|
-
class ProductInfo:
|
|
272
|
-
"""Information about a LIFX product.
|
|
273
|
-
|
|
274
|
-
Attributes:
|
|
275
|
-
pid: Product ID
|
|
276
|
-
name: Product name
|
|
277
|
-
vendor: Vendor ID (always 1 for LIFX)
|
|
278
|
-
capabilities: Bitfield of ProductCapability flags
|
|
279
|
-
temperature_range: Min/max color temperature in Kelvin
|
|
280
|
-
min_ext_mz_firmware: Minimum firmware version for extended multizone
|
|
281
|
-
"""
|
|
282
|
-
|
|
283
|
-
pid: int
|
|
284
|
-
name: str
|
|
285
|
-
vendor: int
|
|
286
|
-
capabilities: int
|
|
287
|
-
temperature_range: TemperatureRange | None
|
|
288
|
-
min_ext_mz_firmware: int | None
|
|
289
|
-
|
|
290
|
-
def has_capability(self, capability: ProductCapability) -> bool:
|
|
291
|
-
"""Check if product has a specific capability.
|
|
292
|
-
|
|
293
|
-
Args:
|
|
294
|
-
capability: Capability to check
|
|
295
|
-
|
|
296
|
-
Returns:
|
|
297
|
-
True if product has the capability
|
|
298
|
-
"""
|
|
299
|
-
return bool(self.capabilities & capability)
|
|
300
|
-
|
|
301
|
-
@property
|
|
302
|
-
def has_color(self) -> bool:
|
|
303
|
-
"""Check if product supports color."""
|
|
304
|
-
return self.has_capability(ProductCapability.COLOR)
|
|
305
|
-
|
|
306
|
-
@property
|
|
307
|
-
def has_infrared(self) -> bool:
|
|
308
|
-
"""Check if product supports infrared."""
|
|
309
|
-
return self.has_capability(ProductCapability.INFRARED)
|
|
310
|
-
|
|
311
|
-
@property
|
|
312
|
-
def has_multizone(self) -> bool:
|
|
313
|
-
"""Check if product supports multizone."""
|
|
314
|
-
return self.has_capability(ProductCapability.MULTIZONE)
|
|
315
|
-
|
|
316
|
-
@property
|
|
317
|
-
def has_chain(self) -> bool:
|
|
318
|
-
"""Check if product supports chaining."""
|
|
319
|
-
return self.has_capability(ProductCapability.CHAIN)
|
|
320
|
-
|
|
321
|
-
@property
|
|
322
|
-
def has_matrix(self) -> bool:
|
|
323
|
-
"""Check if product supports matrix (2D grid)."""
|
|
324
|
-
return self.has_capability(ProductCapability.MATRIX)
|
|
325
|
-
|
|
326
|
-
@property
|
|
327
|
-
def has_relays(self) -> bool:
|
|
328
|
-
"""Check if product has relays."""
|
|
329
|
-
return self.has_capability(ProductCapability.RELAYS)
|
|
330
|
-
|
|
331
|
-
@property
|
|
332
|
-
def has_buttons(self) -> bool:
|
|
333
|
-
"""Check if product has buttons."""
|
|
334
|
-
return self.has_capability(ProductCapability.BUTTONS)
|
|
335
|
-
|
|
336
|
-
@property
|
|
337
|
-
def has_hev(self) -> bool:
|
|
338
|
-
"""Check if product supports HEV."""
|
|
339
|
-
return self.has_capability(ProductCapability.HEV)
|
|
340
|
-
|
|
341
|
-
@property
|
|
342
|
-
def has_extended_multizone(self) -> bool:
|
|
343
|
-
"""Check if product supports extended multizone."""
|
|
344
|
-
return self.has_capability(ProductCapability.EXTENDED_MULTIZONE)
|
|
345
|
-
|
|
346
|
-
def supports_extended_multizone(self, firmware_version: int | None = None) -> bool:
|
|
347
|
-
"""Check if extended multizone is supported for given firmware version.
|
|
348
|
-
|
|
349
|
-
Args:
|
|
350
|
-
firmware_version: Firmware version to check (optional)
|
|
351
|
-
|
|
352
|
-
Returns:
|
|
353
|
-
True if extended multizone is supported
|
|
354
|
-
"""
|
|
355
|
-
if not self.has_extended_multizone:
|
|
356
|
-
return False
|
|
357
|
-
if self.min_ext_mz_firmware is None:
|
|
358
|
-
return True
|
|
359
|
-
if firmware_version is None:
|
|
360
|
-
return True
|
|
361
|
-
return firmware_version >= self.min_ext_mz_firmware
|
|
362
|
-
|
|
363
|
-
@cached_property
|
|
364
|
-
def caps(self) -> str:
|
|
365
|
-
"""Format product capabilities as a human-readable string.
|
|
366
|
-
|
|
367
|
-
Returns:
|
|
368
|
-
Comma-separated capability string (e.g., "color, infrared, multizone")
|
|
369
|
-
"""
|
|
370
|
-
caps = []
|
|
371
|
-
|
|
372
|
-
if self.has_buttons:
|
|
373
|
-
caps.append("buttons")
|
|
374
|
-
|
|
375
|
-
if self.has_relays:
|
|
376
|
-
caps.append("relays")
|
|
377
|
-
elif self.has_color:
|
|
378
|
-
caps.append("color")
|
|
379
|
-
else:
|
|
380
|
-
# Check temperature range to determine white light type
|
|
381
|
-
if self.temperature_range:
|
|
382
|
-
if self.temperature_range.min != self.temperature_range.max:
|
|
383
|
-
caps.append("color temperature")
|
|
384
|
-
else:
|
|
385
|
-
caps.append("brightness only")
|
|
386
|
-
else:
|
|
387
|
-
# No temperature range info, assume basic brightness
|
|
388
|
-
caps.append("brightness only")
|
|
389
|
-
|
|
390
|
-
# Add additional capabilities
|
|
391
|
-
if self.has_infrared:
|
|
392
|
-
caps.append("infrared")
|
|
393
|
-
# Extended multizone is backwards compatible with multizone,
|
|
394
|
-
# so only show multizone if extended multizone is not present
|
|
395
|
-
if self.has_extended_multizone:
|
|
396
|
-
caps.append("extended-multizone")
|
|
397
|
-
elif self.has_multizone:
|
|
398
|
-
caps.append("multizone")
|
|
399
|
-
if self.has_matrix:
|
|
400
|
-
caps.append("matrix")
|
|
401
|
-
if self.has_hev:
|
|
402
|
-
caps.append("HEV")
|
|
403
|
-
if self.has_chain:
|
|
404
|
-
caps.append("chain")
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
return ", ".join(caps) if caps else "unknown"
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
'''
|
|
411
|
-
|
|
412
|
-
# Generate product definitions
|
|
413
|
-
products_code = generate_product_definitions(products_data)
|
|
414
|
-
|
|
415
|
-
# Generate helper functions
|
|
416
|
-
helper_functions = '''
|
|
417
|
-
|
|
418
|
-
class ProductRegistry:
|
|
419
|
-
"""Registry of LIFX products and their capabilities."""
|
|
420
|
-
|
|
421
|
-
def __init__(self) -> None:
|
|
422
|
-
"""Initialize product registry with pre-generated data."""
|
|
423
|
-
self._products = PRODUCTS.copy() # Copy to allow test overrides
|
|
424
|
-
self._loaded = True # Always loaded in generated registry
|
|
425
|
-
|
|
426
|
-
def load_from_dict(self, data: dict | list) -> None:
|
|
427
|
-
"""Load products from parsed JSON data (for testing).
|
|
428
|
-
|
|
429
|
-
Args:
|
|
430
|
-
data: Parsed products.json dictionary or array
|
|
431
|
-
"""
|
|
432
|
-
from typing import Any
|
|
433
|
-
|
|
434
|
-
# Clear existing products
|
|
435
|
-
self._products.clear()
|
|
436
|
-
|
|
437
|
-
# Handle both array and object formats
|
|
438
|
-
all_vendors = []
|
|
439
|
-
if isinstance(data, list):
|
|
440
|
-
all_vendors = data
|
|
441
|
-
else:
|
|
442
|
-
all_vendors = [data]
|
|
443
|
-
|
|
444
|
-
# Process each vendor
|
|
445
|
-
for vendor_data in all_vendors:
|
|
446
|
-
vendor_id = vendor_data.get("vid", 1)
|
|
447
|
-
defaults = vendor_data.get("defaults", {})
|
|
448
|
-
default_features = defaults.get("features", {})
|
|
449
|
-
|
|
450
|
-
# Parse each product
|
|
451
|
-
for product in vendor_data.get("products", []):
|
|
452
|
-
pid = product["pid"]
|
|
453
|
-
name = product["name"]
|
|
454
|
-
|
|
455
|
-
# Merge features with defaults
|
|
456
|
-
prod_features = product.get("features", {})
|
|
457
|
-
features: dict[str, Any] = {**default_features, **prod_features}
|
|
458
|
-
|
|
459
|
-
# Build capabilities bitfield
|
|
460
|
-
capabilities = 0
|
|
461
|
-
if features.get("color"):
|
|
462
|
-
capabilities |= ProductCapability.COLOR
|
|
463
|
-
if features.get("infrared"):
|
|
464
|
-
capabilities |= ProductCapability.INFRARED
|
|
465
|
-
if features.get("multizone"):
|
|
466
|
-
capabilities |= ProductCapability.MULTIZONE
|
|
467
|
-
if features.get("chain"):
|
|
468
|
-
capabilities |= ProductCapability.CHAIN
|
|
469
|
-
if features.get("matrix"):
|
|
470
|
-
capabilities |= ProductCapability.MATRIX
|
|
471
|
-
if features.get("relays"):
|
|
472
|
-
capabilities |= ProductCapability.RELAYS
|
|
473
|
-
if features.get("buttons"):
|
|
474
|
-
capabilities |= ProductCapability.BUTTONS
|
|
475
|
-
if features.get("hev"):
|
|
476
|
-
capabilities |= ProductCapability.HEV
|
|
477
|
-
|
|
478
|
-
# Check for extended multizone capability
|
|
479
|
-
min_ext_mz_firmware = None
|
|
480
|
-
|
|
481
|
-
# First check if it's a native feature (no firmware requirement)
|
|
482
|
-
if features.get("extended_multizone"):
|
|
483
|
-
capabilities |= ProductCapability.EXTENDED_MULTIZONE
|
|
484
|
-
else:
|
|
485
|
-
# Check if it's available as an upgrade (requires minimum firmware)
|
|
486
|
-
for upgrade in product.get("upgrades", []):
|
|
487
|
-
if upgrade.get("features", {}).get("extended_multizone"):
|
|
488
|
-
capabilities |= ProductCapability.EXTENDED_MULTIZONE
|
|
489
|
-
# Parse firmware version (major.minor format)
|
|
490
|
-
major = upgrade.get("major", 0)
|
|
491
|
-
minor = upgrade.get("minor", 0)
|
|
492
|
-
min_ext_mz_firmware = (major << 16) | minor
|
|
493
|
-
break
|
|
494
|
-
|
|
495
|
-
# Parse temperature range
|
|
496
|
-
temp_range = None
|
|
497
|
-
if "temperature_range" in features:
|
|
498
|
-
temp_list = features["temperature_range"]
|
|
499
|
-
if len(temp_list) >= 2:
|
|
500
|
-
temp_range = TemperatureRange(
|
|
501
|
-
min=temp_list[0], max=temp_list[1]
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
product_info = ProductInfo(
|
|
505
|
-
pid=pid,
|
|
506
|
-
name=name,
|
|
507
|
-
vendor=vendor_id,
|
|
508
|
-
capabilities=capabilities,
|
|
509
|
-
temperature_range=temp_range,
|
|
510
|
-
min_ext_mz_firmware=min_ext_mz_firmware,
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
self._products[pid] = product_info
|
|
514
|
-
|
|
515
|
-
self._loaded = True
|
|
516
|
-
|
|
517
|
-
@property
|
|
518
|
-
def is_loaded(self) -> bool:
|
|
519
|
-
"""Check if registry has been loaded."""
|
|
520
|
-
return self._loaded
|
|
521
|
-
|
|
522
|
-
def get_product(self, pid: int) -> ProductInfo | None:
|
|
523
|
-
"""Get product info by product ID.
|
|
524
|
-
|
|
525
|
-
Args:
|
|
526
|
-
pid: Product ID
|
|
527
|
-
|
|
528
|
-
Returns:
|
|
529
|
-
ProductInfo if found, None otherwise
|
|
530
|
-
"""
|
|
531
|
-
return self._products.get(pid)
|
|
532
|
-
|
|
533
|
-
def get_device_class_name(
|
|
534
|
-
self, pid: int, firmware_version: int | None = None
|
|
535
|
-
) -> str:
|
|
536
|
-
"""Get appropriate device class name for a product.
|
|
537
|
-
|
|
538
|
-
Args:
|
|
539
|
-
pid: Product ID
|
|
540
|
-
firmware_version: Firmware version (optional)
|
|
541
|
-
|
|
542
|
-
Returns:
|
|
543
|
-
Device class name: "TileDevice", "MultiZoneLight", "HevLight",
|
|
544
|
-
"InfraredLight", "Light", or "Device"
|
|
545
|
-
"""
|
|
546
|
-
product = self.get_product(pid)
|
|
547
|
-
if product is None:
|
|
548
|
-
# Unknown product - default to Light if we don't know
|
|
549
|
-
return "Light"
|
|
550
|
-
|
|
551
|
-
# Matrix devices (Tiles, Candles) → TileDevice
|
|
552
|
-
if product.has_matrix:
|
|
553
|
-
return "TileDevice"
|
|
554
|
-
|
|
555
|
-
# MultiZone devices (Strips, Beams) → MultiZoneLight
|
|
556
|
-
if product.has_multizone:
|
|
557
|
-
return "MultiZoneLight"
|
|
558
|
-
|
|
559
|
-
# HEV lights → HevLight
|
|
560
|
-
if product.has_hev:
|
|
561
|
-
return "HevLight"
|
|
562
|
-
|
|
563
|
-
# Infrared lights → InfraredLight
|
|
564
|
-
if product.has_infrared:
|
|
565
|
-
return "InfraredLight"
|
|
566
|
-
|
|
567
|
-
# Color lights → Light
|
|
568
|
-
if product.has_color:
|
|
569
|
-
return "Light"
|
|
570
|
-
|
|
571
|
-
# Devices with relays (switches/relays) → Device
|
|
572
|
-
if product.has_relays:
|
|
573
|
-
return "Device"
|
|
574
|
-
|
|
575
|
-
# Devices with buttons but no color (switches) → Device
|
|
576
|
-
if product.has_buttons:
|
|
577
|
-
return "Device"
|
|
578
|
-
|
|
579
|
-
# Everything else (basic lights, white-to-warm lights) → Light
|
|
580
|
-
# These have no special capabilities but still support Light protocol
|
|
581
|
-
return "Light"
|
|
582
|
-
|
|
583
|
-
def __len__(self) -> int:
|
|
584
|
-
"""Get number of products in registry."""
|
|
585
|
-
return len(self._products)
|
|
586
|
-
|
|
587
|
-
def __contains__(self, pid: int) -> bool:
|
|
588
|
-
"""Check if product ID exists in registry."""
|
|
589
|
-
return pid in self._products
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
# Global registry instance
|
|
593
|
-
_registry = ProductRegistry()
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
def get_registry() -> ProductRegistry:
|
|
597
|
-
"""Get the global product registry.
|
|
598
|
-
|
|
599
|
-
Returns:
|
|
600
|
-
Global ProductRegistry instance
|
|
601
|
-
"""
|
|
602
|
-
return _registry
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
def get_product(pid: int) -> ProductInfo | None:
|
|
606
|
-
"""Get product info by product ID.
|
|
607
|
-
|
|
608
|
-
Args:
|
|
609
|
-
pid: Product ID
|
|
610
|
-
|
|
611
|
-
Returns:
|
|
612
|
-
ProductInfo if found, None otherwise
|
|
613
|
-
"""
|
|
614
|
-
return _registry.get_product(pid)
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
def get_device_class_name(pid: int, firmware_version: int | None = None) -> str:
|
|
618
|
-
"""Get appropriate device class name for a product.
|
|
619
|
-
|
|
620
|
-
Args:
|
|
621
|
-
pid: Product ID
|
|
622
|
-
firmware_version: Firmware version (optional)
|
|
623
|
-
|
|
624
|
-
Returns:
|
|
625
|
-
Device class name: "TileDevice", "MultiZoneLight", "Light", or "Device"
|
|
626
|
-
"""
|
|
627
|
-
return _registry.get_device_class_name(pid, firmware_version)
|
|
628
|
-
'''
|
|
629
|
-
|
|
630
|
-
return header + products_code + helper_functions
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
def _load_existing_specs(specs_path: Path) -> dict[int, dict[str, Any]]:
|
|
634
|
-
"""Load existing specs from YAML file.
|
|
635
|
-
|
|
636
|
-
Args:
|
|
637
|
-
specs_path: Path to specs.yml file
|
|
638
|
-
|
|
639
|
-
Returns:
|
|
640
|
-
Dictionary of product specs keyed by PID
|
|
641
|
-
"""
|
|
642
|
-
if not specs_path.exists():
|
|
643
|
-
return {}
|
|
644
|
-
|
|
645
|
-
with open(specs_path) as f:
|
|
646
|
-
specs_data = yaml.safe_load(f)
|
|
647
|
-
if specs_data and "products" in specs_data:
|
|
648
|
-
return specs_data["products"]
|
|
649
|
-
|
|
650
|
-
return {}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
def _discover_new_products(
|
|
654
|
-
products_data: dict[str, Any] | list[dict[str, Any]],
|
|
655
|
-
existing_specs: dict[int, dict[str, Any]],
|
|
656
|
-
) -> list[dict[str, Any]]:
|
|
657
|
-
"""Find new multizone, matrix, or switch products that need specs templates.
|
|
658
|
-
|
|
659
|
-
Args:
|
|
660
|
-
products_data: Parsed products.json data
|
|
661
|
-
existing_specs: Existing product specs
|
|
662
|
-
|
|
663
|
-
Returns:
|
|
664
|
-
List of new product dictionaries with metadata
|
|
665
|
-
"""
|
|
666
|
-
all_vendors = []
|
|
667
|
-
if isinstance(products_data, list):
|
|
668
|
-
all_vendors = products_data
|
|
669
|
-
else:
|
|
670
|
-
all_vendors = [products_data]
|
|
671
|
-
|
|
672
|
-
new_products = []
|
|
673
|
-
for vendor_data in all_vendors:
|
|
674
|
-
defaults = vendor_data.get("defaults", {})
|
|
675
|
-
default_features = defaults.get("features", {})
|
|
676
|
-
|
|
677
|
-
for product in vendor_data.get("products", []):
|
|
678
|
-
pid = product["pid"]
|
|
679
|
-
features = {**default_features, **product.get("features", {})}
|
|
680
|
-
|
|
681
|
-
# Check if this product needs specs template
|
|
682
|
-
if pid not in existing_specs:
|
|
683
|
-
is_multizone = features.get("multizone", False)
|
|
684
|
-
is_matrix = features.get("matrix", False)
|
|
685
|
-
is_switch = features.get("relays", False)
|
|
686
|
-
|
|
687
|
-
if is_multizone or is_matrix or is_switch:
|
|
688
|
-
new_product = {
|
|
689
|
-
"pid": pid,
|
|
690
|
-
"name": product["name"],
|
|
691
|
-
"multizone": is_multizone,
|
|
692
|
-
"matrix": is_matrix,
|
|
693
|
-
"switch": is_switch,
|
|
694
|
-
"extended_multizone": False,
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
# Check for extended multizone in upgrades
|
|
698
|
-
for upgrade in product.get("upgrades", []):
|
|
699
|
-
if upgrade.get("features", {}).get("extended_multizone"):
|
|
700
|
-
new_product["extended_multizone"] = True
|
|
701
|
-
break
|
|
702
|
-
|
|
703
|
-
new_products.append(new_product)
|
|
704
|
-
|
|
705
|
-
return new_products
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
def _add_product_templates(
|
|
709
|
-
new_products: list[dict[str, Any]], existing_specs: dict[int, dict[str, Any]]
|
|
710
|
-
) -> None:
|
|
711
|
-
"""Add templates for new products to existing specs.
|
|
712
|
-
|
|
713
|
-
Args:
|
|
714
|
-
new_products: List of new product dictionaries
|
|
715
|
-
existing_specs: Existing product specs (modified in place)
|
|
716
|
-
"""
|
|
717
|
-
for product in new_products:
|
|
718
|
-
product_name = product["name"].replace('"', '\\"')
|
|
719
|
-
|
|
720
|
-
if product["switch"]:
|
|
721
|
-
existing_specs[product["pid"]] = {
|
|
722
|
-
"relay_count": 2,
|
|
723
|
-
"notes": product_name,
|
|
724
|
-
}
|
|
725
|
-
elif product["multizone"]:
|
|
726
|
-
existing_specs[product["pid"]] = {
|
|
727
|
-
"default_zone_count": 16,
|
|
728
|
-
"min_zone_count": 1,
|
|
729
|
-
"max_zone_count": 16,
|
|
730
|
-
"notes": product_name,
|
|
731
|
-
}
|
|
732
|
-
elif product["matrix"]:
|
|
733
|
-
existing_specs[product["pid"]] = {
|
|
734
|
-
"default_tile_count": 1,
|
|
735
|
-
"min_tile_count": 1,
|
|
736
|
-
"max_tile_count": 1,
|
|
737
|
-
"tile_width": 8,
|
|
738
|
-
"tile_height": 8,
|
|
739
|
-
"notes": product_name,
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
def _categorize_products(
|
|
744
|
-
existing_specs: dict[int, dict[str, Any]],
|
|
745
|
-
) -> tuple[list[int], list[int], list[int]]:
|
|
746
|
-
"""Categorize products into switch, multizone, and matrix.
|
|
747
|
-
|
|
748
|
-
Args:
|
|
749
|
-
existing_specs: Product specs dictionary
|
|
750
|
-
|
|
751
|
-
Returns:
|
|
752
|
-
Tuple of (sorted_switch_pids, sorted_multizone_pids, sorted_matrix_pids)
|
|
753
|
-
"""
|
|
754
|
-
switch_pids = []
|
|
755
|
-
multizone_pids = []
|
|
756
|
-
matrix_pids = []
|
|
757
|
-
|
|
758
|
-
for pid, specs in existing_specs.items():
|
|
759
|
-
if "relay_count" in specs:
|
|
760
|
-
switch_pids.append(pid)
|
|
761
|
-
elif "tile_width" in specs or "tile_height" in specs:
|
|
762
|
-
matrix_pids.append(pid)
|
|
763
|
-
elif "default_zone_count" in specs:
|
|
764
|
-
multizone_pids.append(pid)
|
|
765
|
-
|
|
766
|
-
switch_pids.sort()
|
|
767
|
-
multizone_pids.sort()
|
|
768
|
-
matrix_pids.sort()
|
|
769
|
-
|
|
770
|
-
return switch_pids, multizone_pids, matrix_pids
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
def _generate_yaml_header() -> list[str]:
|
|
774
|
-
"""Generate YAML file header with documentation.
|
|
775
|
-
|
|
776
|
-
Returns:
|
|
777
|
-
List of header lines
|
|
778
|
-
"""
|
|
779
|
-
return [
|
|
780
|
-
"# LIFX Product Specs and Defaults",
|
|
781
|
-
"# =================================",
|
|
782
|
-
"#",
|
|
783
|
-
"# This file contains product-specific details that are not available in the",
|
|
784
|
-
"# upstream LIFX products.json catalog, such as default zone counts, tile",
|
|
785
|
-
"# configurations, firmware versions, and other device-specific defaults.",
|
|
786
|
-
"#",
|
|
787
|
-
"# These values are used by the emulator to create realistic device",
|
|
788
|
-
"# configurations when specific parameters are not provided by the user.",
|
|
789
|
-
"#",
|
|
790
|
-
"# Format:",
|
|
791
|
-
"# -------",
|
|
792
|
-
"# products:",
|
|
793
|
-
"# <product_id>:",
|
|
794
|
-
"# # For multizone devices",
|
|
795
|
-
"# default_zone_count: <number> # Default zones (e.g., 16)",
|
|
796
|
-
"# min_zone_count: <number> # Minimum zones supported",
|
|
797
|
-
"# max_zone_count: <number> # Maximum zones supported",
|
|
798
|
-
"#",
|
|
799
|
-
"# # For matrix devices (tiles, candles, etc.)",
|
|
800
|
-
"# default_tile_count: <number> # Default number of tiles in chain",
|
|
801
|
-
"# min_tile_count: <number> # Minimum tiles supported",
|
|
802
|
-
"# max_tile_count: <number> # Maximum tiles supported",
|
|
803
|
-
"# tile_width: <number> # Width of each tile in zones",
|
|
804
|
-
"# tile_height: <number> # Height of each tile in zones",
|
|
805
|
-
"#",
|
|
806
|
-
"# # Host firmware version (optional, overrides auto firmware selection)",
|
|
807
|
-
"# default_firmware_major: <number> # Firmware major version (e.g., 3)",
|
|
808
|
-
"# default_firmware_minor: <number> # Firmware minor version (e.g., 70)",
|
|
809
|
-
"#",
|
|
810
|
-
"# # Other device-specific defaults",
|
|
811
|
-
'# notes: "<string>" # Notes about product',
|
|
812
|
-
"#",
|
|
813
|
-
"# Firmware Version Notes:",
|
|
814
|
-
"# ----------------------",
|
|
815
|
-
"# If default_firmware_major and default_firmware_minor are both specified ",
|
|
816
|
-
"# they will be used as the default firmware version when creating devices",
|
|
817
|
-
"# of that type. This overrides the automatic firmware version selection",
|
|
818
|
-
"# based on extended_multizone capability (which defaults to 3.70 for extended",
|
|
819
|
-
"# multizone or 2.60 for non-extended).",
|
|
820
|
-
"#",
|
|
821
|
-
"# Precedence order for firmware version:",
|
|
822
|
-
"# 1. Explicit firmware_version parameter to create_device()",
|
|
823
|
-
"# 2. Product-specific default from this specs.yml file",
|
|
824
|
-
"# 3. Automatic selection based on extended_multizone flag",
|
|
825
|
-
"",
|
|
826
|
-
"products:",
|
|
827
|
-
]
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
def _generate_switch_section(
|
|
831
|
-
switch_pids: list[int], existing_specs: dict[int, dict[str, Any]]
|
|
832
|
-
) -> list[str]:
|
|
833
|
-
"""Generate YAML lines for switch products section.
|
|
834
|
-
|
|
835
|
-
Args:
|
|
836
|
-
switch_pids: Sorted list of switch product IDs
|
|
837
|
-
existing_specs: Product specs dictionary
|
|
838
|
-
|
|
839
|
-
Returns:
|
|
840
|
-
List of YAML lines
|
|
841
|
-
"""
|
|
842
|
-
if not switch_pids:
|
|
843
|
-
return []
|
|
844
|
-
|
|
845
|
-
lines = [
|
|
846
|
-
" # ========================================",
|
|
847
|
-
" # Switch Products (Relays)",
|
|
848
|
-
" # ========================================",
|
|
849
|
-
"",
|
|
850
|
-
]
|
|
851
|
-
|
|
852
|
-
for pid in switch_pids:
|
|
853
|
-
specs = existing_specs[pid]
|
|
854
|
-
name = specs.get("notes", f"Product {pid}").split(" - ")[0]
|
|
855
|
-
|
|
856
|
-
lines.append(f" {pid}: # {name}")
|
|
857
|
-
lines.append(f" relay_count: {specs['relay_count']}")
|
|
858
|
-
|
|
859
|
-
# Add firmware version if present
|
|
860
|
-
if "default_firmware_major" in specs and "default_firmware_minor" in specs:
|
|
861
|
-
lines.append(
|
|
862
|
-
f" default_firmware_major: {specs['default_firmware_major']}"
|
|
863
|
-
)
|
|
864
|
-
lines.append(
|
|
865
|
-
f" default_firmware_minor: {specs['default_firmware_minor']}"
|
|
866
|
-
)
|
|
867
|
-
|
|
868
|
-
notes = specs.get("notes", "")
|
|
869
|
-
if notes:
|
|
870
|
-
notes_escaped = notes.replace('"', '\\"')
|
|
871
|
-
lines.append(f' notes: "{notes_escaped}"')
|
|
872
|
-
lines.append("")
|
|
873
|
-
|
|
874
|
-
return lines
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
def _generate_multizone_section(
|
|
878
|
-
multizone_pids: list[int], existing_specs: dict[int, dict[str, Any]]
|
|
879
|
-
) -> list[str]:
|
|
880
|
-
"""Generate YAML lines for multizone products section.
|
|
881
|
-
|
|
882
|
-
Args:
|
|
883
|
-
multizone_pids: Sorted list of multizone product IDs
|
|
884
|
-
existing_specs: Product specs dictionary
|
|
885
|
-
|
|
886
|
-
Returns:
|
|
887
|
-
List of YAML lines
|
|
888
|
-
"""
|
|
889
|
-
if not multizone_pids:
|
|
890
|
-
return []
|
|
891
|
-
|
|
892
|
-
lines = [
|
|
893
|
-
" # ========================================",
|
|
894
|
-
" # Multizone Products (Linear Strips)",
|
|
895
|
-
" # ========================================",
|
|
896
|
-
"",
|
|
897
|
-
]
|
|
898
|
-
|
|
899
|
-
for pid in multizone_pids:
|
|
900
|
-
specs = existing_specs[pid]
|
|
901
|
-
name = specs.get("notes", f"Product {pid}").split(" - ")[0]
|
|
902
|
-
|
|
903
|
-
lines.append(f" {pid}: # {name}")
|
|
904
|
-
lines.append(f" default_zone_count: {specs['default_zone_count']}")
|
|
905
|
-
lines.append(f" min_zone_count: {specs['min_zone_count']}")
|
|
906
|
-
lines.append(f" max_zone_count: {specs['max_zone_count']}")
|
|
907
|
-
|
|
908
|
-
# Add firmware version if present
|
|
909
|
-
if "default_firmware_major" in specs and "default_firmware_minor" in specs:
|
|
910
|
-
lines.append(
|
|
911
|
-
f" default_firmware_major: {specs['default_firmware_major']}"
|
|
912
|
-
)
|
|
913
|
-
lines.append(
|
|
914
|
-
f" default_firmware_minor: {specs['default_firmware_minor']}"
|
|
915
|
-
)
|
|
916
|
-
|
|
917
|
-
notes = specs.get("notes", "")
|
|
918
|
-
if notes:
|
|
919
|
-
notes_escaped = notes.replace('"', '\\"')
|
|
920
|
-
lines.append(f' notes: "{notes_escaped}"')
|
|
921
|
-
lines.append("")
|
|
922
|
-
|
|
923
|
-
return lines
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
def _generate_matrix_section(
|
|
927
|
-
matrix_pids: list[int], existing_specs: dict[int, dict[str, Any]]
|
|
928
|
-
) -> list[str]:
|
|
929
|
-
"""Generate YAML lines for matrix products section.
|
|
930
|
-
|
|
931
|
-
Args:
|
|
932
|
-
matrix_pids: Sorted list of matrix product IDs
|
|
933
|
-
existing_specs: Product specs dictionary
|
|
934
|
-
|
|
935
|
-
Returns:
|
|
936
|
-
List of YAML lines
|
|
937
|
-
"""
|
|
938
|
-
if not matrix_pids:
|
|
939
|
-
return []
|
|
940
|
-
|
|
941
|
-
lines = [
|
|
942
|
-
" # ========================================",
|
|
943
|
-
" # Matrix Products (Tiles, Candles, etc.)",
|
|
944
|
-
" # ========================================",
|
|
945
|
-
"",
|
|
946
|
-
]
|
|
947
|
-
|
|
948
|
-
for pid in matrix_pids:
|
|
949
|
-
specs = existing_specs[pid]
|
|
950
|
-
name = specs.get("notes", f"Product {pid}").split(" - ")[0]
|
|
951
|
-
|
|
952
|
-
lines.append(f" {pid}: # {name}")
|
|
953
|
-
lines.append(f" default_tile_count: {specs['default_tile_count']}")
|
|
954
|
-
lines.append(f" min_tile_count: {specs['min_tile_count']}")
|
|
955
|
-
lines.append(f" max_tile_count: {specs['max_tile_count']}")
|
|
956
|
-
lines.append(f" tile_width: {specs['tile_width']}")
|
|
957
|
-
lines.append(f" tile_height: {specs['tile_height']}")
|
|
958
|
-
|
|
959
|
-
# Add firmware version if present
|
|
960
|
-
if "default_firmware_major" in specs and "default_firmware_minor" in specs:
|
|
961
|
-
lines.append(
|
|
962
|
-
f" default_firmware_major: {specs['default_firmware_major']}"
|
|
963
|
-
)
|
|
964
|
-
lines.append(
|
|
965
|
-
f" default_firmware_minor: {specs['default_firmware_minor']}"
|
|
966
|
-
)
|
|
967
|
-
|
|
968
|
-
notes = specs.get("notes", "")
|
|
969
|
-
if notes:
|
|
970
|
-
notes_escaped = notes.replace('"', '\\"')
|
|
971
|
-
lines.append(f' notes: "{notes_escaped}"')
|
|
972
|
-
lines.append("")
|
|
973
|
-
|
|
974
|
-
return lines
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
def update_specs_file(
|
|
978
|
-
products_data: dict[str, Any] | list[dict[str, Any]], specs_path: Path
|
|
979
|
-
) -> None:
|
|
980
|
-
"""Update specs.yml with templates for new products and sort all entries by PID.
|
|
981
|
-
|
|
982
|
-
Args:
|
|
983
|
-
products_data: Parsed products.json data
|
|
984
|
-
specs_path: Path to specs.yml file
|
|
985
|
-
"""
|
|
986
|
-
# Load existing specs
|
|
987
|
-
existing_specs = _load_existing_specs(specs_path)
|
|
988
|
-
|
|
989
|
-
# Find new products that need specs
|
|
990
|
-
new_products = _discover_new_products(products_data, existing_specs)
|
|
991
|
-
|
|
992
|
-
# Print status
|
|
993
|
-
if not new_products:
|
|
994
|
-
print("No new multizone or matrix products found - specs.yml is up to date")
|
|
995
|
-
if existing_specs:
|
|
996
|
-
print("Sorting existing specs entries by product ID...")
|
|
997
|
-
else:
|
|
998
|
-
return
|
|
999
|
-
else:
|
|
1000
|
-
print(f"\nFound {len(new_products)} new products that need specs:")
|
|
1001
|
-
for product in new_products:
|
|
1002
|
-
print(f" PID {product['pid']:>3}: {product['name']}")
|
|
1003
|
-
|
|
1004
|
-
# Add templates for new products
|
|
1005
|
-
_add_product_templates(new_products, existing_specs)
|
|
1006
|
-
|
|
1007
|
-
# Categorize products and sort
|
|
1008
|
-
switch_pids, multizone_pids, matrix_pids = _categorize_products(existing_specs)
|
|
1009
|
-
|
|
1010
|
-
# Build YAML content
|
|
1011
|
-
lines = _generate_yaml_header()
|
|
1012
|
-
lines.extend(_generate_multizone_section(multizone_pids, existing_specs))
|
|
1013
|
-
lines.extend(_generate_matrix_section(matrix_pids, existing_specs))
|
|
1014
|
-
lines.extend(_generate_switch_section(switch_pids, existing_specs))
|
|
1015
|
-
|
|
1016
|
-
# Write the new file
|
|
1017
|
-
with open(specs_path, "w") as f:
|
|
1018
|
-
f.write("\n".join(lines))
|
|
1019
|
-
|
|
1020
|
-
# Print completion message
|
|
1021
|
-
if new_products:
|
|
1022
|
-
print(
|
|
1023
|
-
f"\n✓ Added {len(new_products)} new product templates "
|
|
1024
|
-
f"and sorted all entries by PID"
|
|
1025
|
-
)
|
|
1026
|
-
print(
|
|
1027
|
-
" Please review and update the placeholder values "
|
|
1028
|
-
"with actual product specifications"
|
|
1029
|
-
)
|
|
1030
|
-
else:
|
|
1031
|
-
print(f"\n✓ Sorted all {len(existing_specs)} specs entries by product ID")
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
def main() -> None:
|
|
1035
|
-
"""Main generator entry point."""
|
|
1036
|
-
try:
|
|
1037
|
-
# Download and parse products from GitHub
|
|
1038
|
-
products_data = download_products()
|
|
1039
|
-
except Exception as e:
|
|
1040
|
-
print(f"Error: Failed to download products.json: {e}", file=sys.stderr)
|
|
1041
|
-
sys.exit(1)
|
|
1042
|
-
|
|
1043
|
-
# Count products for summary
|
|
1044
|
-
if isinstance(products_data, list):
|
|
1045
|
-
all_products = []
|
|
1046
|
-
for vendor in products_data:
|
|
1047
|
-
all_products.extend(vendor.get("products", []))
|
|
1048
|
-
else:
|
|
1049
|
-
all_products = products_data.get("products", [])
|
|
1050
|
-
|
|
1051
|
-
print(f"Found {len(all_products)} products")
|
|
1052
|
-
|
|
1053
|
-
# Generate registry.py
|
|
1054
|
-
print("\nGenerating registry.py...")
|
|
1055
|
-
registry_code = generate_registry_file(products_data)
|
|
1056
|
-
|
|
1057
|
-
# Determine output path
|
|
1058
|
-
registry_path = Path(__file__).parent / "registry.py"
|
|
1059
|
-
|
|
1060
|
-
with open(registry_path, "w") as f:
|
|
1061
|
-
f.write(registry_code)
|
|
1062
|
-
|
|
1063
|
-
print(f"✓ Generated {registry_path}")
|
|
1064
|
-
|
|
1065
|
-
# Update specs.yml with templates for new products
|
|
1066
|
-
print("\nChecking for new products that need specs...")
|
|
1067
|
-
specs_path = Path(__file__).parent / "specs.yml"
|
|
1068
|
-
|
|
1069
|
-
try:
|
|
1070
|
-
update_specs_file(products_data, specs_path)
|
|
1071
|
-
except Exception as e:
|
|
1072
|
-
print(f"Warning: Failed to update specs.yml: {e}", file=sys.stderr)
|
|
1073
|
-
print("You can manually add specs for new products")
|
|
1074
|
-
|
|
1075
|
-
print("\n✓ Generation complete!")
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
if __name__ == "__main__":
|
|
1079
|
-
main()
|