lifx-emulator 1.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 +31 -0
- lifx_emulator/__main__.py +607 -0
- lifx_emulator/api.py +1825 -0
- lifx_emulator/async_storage.py +308 -0
- lifx_emulator/constants.py +33 -0
- lifx_emulator/device.py +750 -0
- lifx_emulator/device_states.py +114 -0
- lifx_emulator/factories.py +380 -0
- lifx_emulator/handlers/__init__.py +39 -0
- lifx_emulator/handlers/base.py +49 -0
- lifx_emulator/handlers/device_handlers.py +340 -0
- lifx_emulator/handlers/light_handlers.py +372 -0
- lifx_emulator/handlers/multizone_handlers.py +249 -0
- lifx_emulator/handlers/registry.py +110 -0
- lifx_emulator/handlers/tile_handlers.py +309 -0
- lifx_emulator/observers.py +139 -0
- lifx_emulator/products/__init__.py +28 -0
- lifx_emulator/products/generator.py +771 -0
- lifx_emulator/products/registry.py +1446 -0
- lifx_emulator/products/specs.py +242 -0
- lifx_emulator/products/specs.yml +327 -0
- lifx_emulator/protocol/__init__.py +1 -0
- lifx_emulator/protocol/base.py +334 -0
- lifx_emulator/protocol/const.py +8 -0
- lifx_emulator/protocol/generator.py +1371 -0
- lifx_emulator/protocol/header.py +159 -0
- lifx_emulator/protocol/packets.py +1351 -0
- lifx_emulator/protocol/protocol_types.py +844 -0
- lifx_emulator/protocol/serializer.py +379 -0
- lifx_emulator/scenario_manager.py +402 -0
- lifx_emulator/scenario_persistence.py +206 -0
- lifx_emulator/server.py +482 -0
- lifx_emulator/state_restorer.py +259 -0
- lifx_emulator/state_serializer.py +130 -0
- lifx_emulator/storage_protocol.py +100 -0
- lifx_emulator-1.0.0.dist-info/METADATA +445 -0
- lifx_emulator-1.0.0.dist-info/RECORD +40 -0
- lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
- lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
- lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
|
@@ -0,0 +1,771 @@
|
|
|
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 download_products() -> dict[str, Any] | list[dict[str, Any]]:
|
|
21
|
+
"""Download and parse products.json from LIFX GitHub repository.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Parsed products dictionary or list
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
URLError: If download fails
|
|
28
|
+
json.JSONDecodeError: If parsing fails
|
|
29
|
+
"""
|
|
30
|
+
print(f"Downloading products.json from {PRODUCTS_URL}...")
|
|
31
|
+
with urlopen(PRODUCTS_URL) as response: # nosec
|
|
32
|
+
products_data = response.read()
|
|
33
|
+
|
|
34
|
+
print("Parsing products specification...")
|
|
35
|
+
products = json.loads(products_data)
|
|
36
|
+
return products
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def generate_product_definitions(
|
|
40
|
+
products_data: dict[str, Any] | list[dict[str, Any]],
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Generate Python code for product definitions.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
products_data: Parsed products.json data
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Python code string with ProductInfo instances
|
|
49
|
+
"""
|
|
50
|
+
code_lines = []
|
|
51
|
+
|
|
52
|
+
# Handle both array and object formats
|
|
53
|
+
all_vendors = []
|
|
54
|
+
if isinstance(products_data, list):
|
|
55
|
+
# Array format - multiple vendors
|
|
56
|
+
all_vendors = products_data
|
|
57
|
+
else:
|
|
58
|
+
# Object format - single vendor
|
|
59
|
+
all_vendors = [products_data]
|
|
60
|
+
|
|
61
|
+
# Generate product definitions
|
|
62
|
+
code_lines.append("# Pre-generated product definitions")
|
|
63
|
+
code_lines.append("PRODUCTS: dict[int, ProductInfo] = {")
|
|
64
|
+
|
|
65
|
+
product_count = 0
|
|
66
|
+
for vendor_data in all_vendors:
|
|
67
|
+
vendor_id = vendor_data.get("vid", 1)
|
|
68
|
+
|
|
69
|
+
# Get default features
|
|
70
|
+
defaults = vendor_data.get("defaults", {})
|
|
71
|
+
default_features = defaults.get("features", {})
|
|
72
|
+
|
|
73
|
+
# Process each product
|
|
74
|
+
for product in vendor_data.get("products", []):
|
|
75
|
+
pid = product["pid"]
|
|
76
|
+
name = product["name"]
|
|
77
|
+
|
|
78
|
+
# Merge features with defaults
|
|
79
|
+
features = {**default_features, **product.get("features", {})}
|
|
80
|
+
|
|
81
|
+
# Build capabilities bitfield
|
|
82
|
+
capabilities = []
|
|
83
|
+
if features.get("color"):
|
|
84
|
+
capabilities.append("ProductCapability.COLOR")
|
|
85
|
+
if features.get("infrared"):
|
|
86
|
+
capabilities.append("ProductCapability.INFRARED")
|
|
87
|
+
if features.get("multizone"):
|
|
88
|
+
capabilities.append("ProductCapability.MULTIZONE")
|
|
89
|
+
if features.get("chain"):
|
|
90
|
+
capabilities.append("ProductCapability.CHAIN")
|
|
91
|
+
if features.get("matrix"):
|
|
92
|
+
capabilities.append("ProductCapability.MATRIX")
|
|
93
|
+
if features.get("relays"):
|
|
94
|
+
capabilities.append("ProductCapability.RELAYS")
|
|
95
|
+
if features.get("buttons"):
|
|
96
|
+
capabilities.append("ProductCapability.BUTTONS")
|
|
97
|
+
if features.get("hev"):
|
|
98
|
+
capabilities.append("ProductCapability.HEV")
|
|
99
|
+
|
|
100
|
+
# Check for extended multizone in upgrades
|
|
101
|
+
min_ext_mz_firmware = None
|
|
102
|
+
for upgrade in product.get("upgrades", []):
|
|
103
|
+
if upgrade.get("features", {}).get("extended_multizone"):
|
|
104
|
+
capabilities.append("ProductCapability.EXTENDED_MULTIZONE")
|
|
105
|
+
# Parse firmware version (major.minor format)
|
|
106
|
+
major = upgrade.get("major", 0)
|
|
107
|
+
minor = upgrade.get("minor", 0)
|
|
108
|
+
min_ext_mz_firmware = (major << 16) | minor
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
# Build capabilities expression
|
|
112
|
+
if capabilities:
|
|
113
|
+
capabilities_expr = " | ".join(capabilities)
|
|
114
|
+
else:
|
|
115
|
+
capabilities_expr = "0"
|
|
116
|
+
|
|
117
|
+
# Parse temperature range
|
|
118
|
+
temp_range_expr = "None"
|
|
119
|
+
if "temperature_range" in features:
|
|
120
|
+
temp_list = features["temperature_range"]
|
|
121
|
+
if len(temp_list) >= 2:
|
|
122
|
+
temp_range_expr = (
|
|
123
|
+
f"TemperatureRange(min={temp_list[0]}, max={temp_list[1]})"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Format firmware version
|
|
127
|
+
min_ext_mz_firmware_expr = (
|
|
128
|
+
str(min_ext_mz_firmware) if min_ext_mz_firmware is not None else "None"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Generate ProductInfo instantiation
|
|
132
|
+
code_lines.append(f" {pid}: ProductInfo(")
|
|
133
|
+
code_lines.append(f" pid={pid},")
|
|
134
|
+
code_lines.append(f" name={repr(name)},")
|
|
135
|
+
code_lines.append(f" vendor={vendor_id},")
|
|
136
|
+
code_lines.append(f" capabilities={capabilities_expr},")
|
|
137
|
+
code_lines.append(f" temperature_range={temp_range_expr},")
|
|
138
|
+
code_lines.append(
|
|
139
|
+
f" min_ext_mz_firmware={min_ext_mz_firmware_expr},"
|
|
140
|
+
)
|
|
141
|
+
code_lines.append(" ),")
|
|
142
|
+
|
|
143
|
+
product_count += 1
|
|
144
|
+
|
|
145
|
+
code_lines.append("}")
|
|
146
|
+
code_lines.append("")
|
|
147
|
+
|
|
148
|
+
print(f"Generated {product_count} product definitions")
|
|
149
|
+
return "\n".join(code_lines)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def generate_registry_file(products_data: dict[str, Any] | list[dict[str, Any]]) -> str:
|
|
153
|
+
"""Generate complete registry.py file.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
products_data: Parsed products.json data
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Complete Python file content
|
|
160
|
+
"""
|
|
161
|
+
header = '''"""LIFX product definitions and capability detection.
|
|
162
|
+
|
|
163
|
+
DO NOT EDIT THIS FILE MANUALLY.
|
|
164
|
+
Generated from https://github.com/LIFX/products/blob/master/products.json
|
|
165
|
+
by products/generator.py
|
|
166
|
+
|
|
167
|
+
This module provides pre-generated product information for efficient runtime lookups.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
from __future__ import annotations
|
|
171
|
+
|
|
172
|
+
from dataclasses import dataclass
|
|
173
|
+
from enum import IntEnum
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ProductCapability(IntEnum):
|
|
177
|
+
"""Product capability flags."""
|
|
178
|
+
|
|
179
|
+
COLOR = 1
|
|
180
|
+
INFRARED = 2
|
|
181
|
+
MULTIZONE = 4
|
|
182
|
+
CHAIN = 8
|
|
183
|
+
MATRIX = 16
|
|
184
|
+
RELAYS = 32
|
|
185
|
+
BUTTONS = 64
|
|
186
|
+
HEV = 128
|
|
187
|
+
EXTENDED_MULTIZONE = 256
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class TemperatureRange:
|
|
192
|
+
"""Color temperature range in Kelvin."""
|
|
193
|
+
|
|
194
|
+
min: int
|
|
195
|
+
max: int
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class ProductInfo:
|
|
200
|
+
"""Information about a LIFX product.
|
|
201
|
+
|
|
202
|
+
Attributes:
|
|
203
|
+
pid: Product ID
|
|
204
|
+
name: Product name
|
|
205
|
+
vendor: Vendor ID (always 1 for LIFX)
|
|
206
|
+
capabilities: Bitfield of ProductCapability flags
|
|
207
|
+
temperature_range: Min/max color temperature in Kelvin
|
|
208
|
+
min_ext_mz_firmware: Minimum firmware version for extended multizone
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
pid: int
|
|
212
|
+
name: str
|
|
213
|
+
vendor: int
|
|
214
|
+
capabilities: int
|
|
215
|
+
temperature_range: TemperatureRange | None
|
|
216
|
+
min_ext_mz_firmware: int | None
|
|
217
|
+
|
|
218
|
+
def has_capability(self, capability: ProductCapability) -> bool:
|
|
219
|
+
"""Check if product has a specific capability.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
capability: Capability to check
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if product has the capability
|
|
226
|
+
"""
|
|
227
|
+
return bool(self.capabilities & capability)
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def has_color(self) -> bool:
|
|
231
|
+
"""Check if product supports color."""
|
|
232
|
+
return self.has_capability(ProductCapability.COLOR)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def has_infrared(self) -> bool:
|
|
236
|
+
"""Check if product supports infrared."""
|
|
237
|
+
return self.has_capability(ProductCapability.INFRARED)
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def has_multizone(self) -> bool:
|
|
241
|
+
"""Check if product supports multizone."""
|
|
242
|
+
return self.has_capability(ProductCapability.MULTIZONE)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def has_chain(self) -> bool:
|
|
246
|
+
"""Check if product supports chaining."""
|
|
247
|
+
return self.has_capability(ProductCapability.CHAIN)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def has_matrix(self) -> bool:
|
|
251
|
+
"""Check if product supports matrix (2D grid)."""
|
|
252
|
+
return self.has_capability(ProductCapability.MATRIX)
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def has_relays(self) -> bool:
|
|
256
|
+
"""Check if product has relays."""
|
|
257
|
+
return self.has_capability(ProductCapability.RELAYS)
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def has_buttons(self) -> bool:
|
|
261
|
+
"""Check if product has buttons."""
|
|
262
|
+
return self.has_capability(ProductCapability.BUTTONS)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def has_hev(self) -> bool:
|
|
266
|
+
"""Check if product supports HEV."""
|
|
267
|
+
return self.has_capability(ProductCapability.HEV)
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def has_extended_multizone(self) -> bool:
|
|
271
|
+
"""Check if product supports extended multizone."""
|
|
272
|
+
return self.has_capability(ProductCapability.EXTENDED_MULTIZONE)
|
|
273
|
+
|
|
274
|
+
def supports_extended_multizone(self, firmware_version: int | None = None) -> bool:
|
|
275
|
+
"""Check if extended multizone is supported for given firmware version.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
firmware_version: Firmware version to check (optional)
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if extended multizone is supported
|
|
282
|
+
"""
|
|
283
|
+
if not self.has_extended_multizone:
|
|
284
|
+
return False
|
|
285
|
+
if self.min_ext_mz_firmware is None:
|
|
286
|
+
return True
|
|
287
|
+
if firmware_version is None:
|
|
288
|
+
return True
|
|
289
|
+
return firmware_version >= self.min_ext_mz_firmware
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
'''
|
|
293
|
+
|
|
294
|
+
# Generate product definitions
|
|
295
|
+
products_code = generate_product_definitions(products_data)
|
|
296
|
+
|
|
297
|
+
# Generate helper functions
|
|
298
|
+
helper_functions = '''
|
|
299
|
+
|
|
300
|
+
class ProductRegistry:
|
|
301
|
+
"""Registry of LIFX products and their capabilities."""
|
|
302
|
+
|
|
303
|
+
def __init__(self) -> None:
|
|
304
|
+
"""Initialize product registry with pre-generated data."""
|
|
305
|
+
self._products = PRODUCTS.copy() # Copy to allow test overrides
|
|
306
|
+
self._loaded = True # Always loaded in generated registry
|
|
307
|
+
|
|
308
|
+
def load_from_dict(self, data: dict | list) -> None:
|
|
309
|
+
"""Load products from parsed JSON data (for testing).
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
data: Parsed products.json dictionary or array
|
|
313
|
+
"""
|
|
314
|
+
from typing import Any
|
|
315
|
+
|
|
316
|
+
# Clear existing products
|
|
317
|
+
self._products.clear()
|
|
318
|
+
|
|
319
|
+
# Handle both array and object formats
|
|
320
|
+
all_vendors = []
|
|
321
|
+
if isinstance(data, list):
|
|
322
|
+
all_vendors = data
|
|
323
|
+
else:
|
|
324
|
+
all_vendors = [data]
|
|
325
|
+
|
|
326
|
+
# Process each vendor
|
|
327
|
+
for vendor_data in all_vendors:
|
|
328
|
+
vendor_id = vendor_data.get("vid", 1)
|
|
329
|
+
defaults = vendor_data.get("defaults", {})
|
|
330
|
+
default_features = defaults.get("features", {})
|
|
331
|
+
|
|
332
|
+
# Parse each product
|
|
333
|
+
for product in vendor_data.get("products", []):
|
|
334
|
+
pid = product["pid"]
|
|
335
|
+
name = product["name"]
|
|
336
|
+
|
|
337
|
+
# Merge features with defaults
|
|
338
|
+
prod_features = product.get("features", {})
|
|
339
|
+
features: dict[str, Any] = {**default_features, **prod_features}
|
|
340
|
+
|
|
341
|
+
# Build capabilities bitfield
|
|
342
|
+
capabilities = 0
|
|
343
|
+
if features.get("color"):
|
|
344
|
+
capabilities |= ProductCapability.COLOR
|
|
345
|
+
if features.get("infrared"):
|
|
346
|
+
capabilities |= ProductCapability.INFRARED
|
|
347
|
+
if features.get("multizone"):
|
|
348
|
+
capabilities |= ProductCapability.MULTIZONE
|
|
349
|
+
if features.get("chain"):
|
|
350
|
+
capabilities |= ProductCapability.CHAIN
|
|
351
|
+
if features.get("matrix"):
|
|
352
|
+
capabilities |= ProductCapability.MATRIX
|
|
353
|
+
if features.get("relays"):
|
|
354
|
+
capabilities |= ProductCapability.RELAYS
|
|
355
|
+
if features.get("buttons"):
|
|
356
|
+
capabilities |= ProductCapability.BUTTONS
|
|
357
|
+
if features.get("hev"):
|
|
358
|
+
capabilities |= ProductCapability.HEV
|
|
359
|
+
|
|
360
|
+
# Check for extended multizone in upgrades
|
|
361
|
+
min_ext_mz_firmware = None
|
|
362
|
+
for upgrade in product.get("upgrades", []):
|
|
363
|
+
if upgrade.get("features", {}).get("extended_multizone"):
|
|
364
|
+
capabilities |= ProductCapability.EXTENDED_MULTIZONE
|
|
365
|
+
# Parse firmware version (major.minor format)
|
|
366
|
+
major = upgrade.get("major", 0)
|
|
367
|
+
minor = upgrade.get("minor", 0)
|
|
368
|
+
min_ext_mz_firmware = (major << 16) | minor
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
# Parse temperature range
|
|
372
|
+
temp_range = None
|
|
373
|
+
if "temperature_range" in features:
|
|
374
|
+
temp_list = features["temperature_range"]
|
|
375
|
+
if len(temp_list) >= 2:
|
|
376
|
+
temp_range = TemperatureRange(
|
|
377
|
+
min=temp_list[0], max=temp_list[1]
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
product_info = ProductInfo(
|
|
381
|
+
pid=pid,
|
|
382
|
+
name=name,
|
|
383
|
+
vendor=vendor_id,
|
|
384
|
+
capabilities=capabilities,
|
|
385
|
+
temperature_range=temp_range,
|
|
386
|
+
min_ext_mz_firmware=min_ext_mz_firmware,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
self._products[pid] = product_info
|
|
390
|
+
|
|
391
|
+
self._loaded = True
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def is_loaded(self) -> bool:
|
|
395
|
+
"""Check if registry has been loaded."""
|
|
396
|
+
return self._loaded
|
|
397
|
+
|
|
398
|
+
def get_product(self, pid: int) -> ProductInfo | None:
|
|
399
|
+
"""Get product info by product ID.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
pid: Product ID
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
ProductInfo if found, None otherwise
|
|
406
|
+
"""
|
|
407
|
+
return self._products.get(pid)
|
|
408
|
+
|
|
409
|
+
def get_device_class_name(
|
|
410
|
+
self, pid: int, firmware_version: int | None = None
|
|
411
|
+
) -> str:
|
|
412
|
+
"""Get appropriate device class name for a product.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
pid: Product ID
|
|
416
|
+
firmware_version: Firmware version (optional)
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Device class name: "TileDevice", "MultiZoneLight", "HevLight",
|
|
420
|
+
"InfraredLight", "Light", or "Device"
|
|
421
|
+
"""
|
|
422
|
+
product = self.get_product(pid)
|
|
423
|
+
if product is None:
|
|
424
|
+
# Unknown product - default to Light if we don't know
|
|
425
|
+
return "Light"
|
|
426
|
+
|
|
427
|
+
# Matrix devices (Tiles, Candles) → TileDevice
|
|
428
|
+
if product.has_matrix:
|
|
429
|
+
return "TileDevice"
|
|
430
|
+
|
|
431
|
+
# MultiZone devices (Strips, Beams) → MultiZoneLight
|
|
432
|
+
if product.has_multizone:
|
|
433
|
+
return "MultiZoneLight"
|
|
434
|
+
|
|
435
|
+
# HEV lights → HevLight
|
|
436
|
+
if product.has_hev:
|
|
437
|
+
return "HevLight"
|
|
438
|
+
|
|
439
|
+
# Infrared lights → InfraredLight
|
|
440
|
+
if product.has_infrared:
|
|
441
|
+
return "InfraredLight"
|
|
442
|
+
|
|
443
|
+
# Color lights → Light
|
|
444
|
+
if product.has_color:
|
|
445
|
+
return "Light"
|
|
446
|
+
|
|
447
|
+
# Devices with relays (switches/relays) → Device
|
|
448
|
+
if product.has_relays:
|
|
449
|
+
return "Device"
|
|
450
|
+
|
|
451
|
+
# Devices with buttons but no color (switches) → Device
|
|
452
|
+
if product.has_buttons:
|
|
453
|
+
return "Device"
|
|
454
|
+
|
|
455
|
+
# Everything else (basic lights, white-to-warm lights) → Light
|
|
456
|
+
# These have no special capabilities but still support Light protocol
|
|
457
|
+
return "Light"
|
|
458
|
+
|
|
459
|
+
def __len__(self) -> int:
|
|
460
|
+
"""Get number of products in registry."""
|
|
461
|
+
return len(self._products)
|
|
462
|
+
|
|
463
|
+
def __contains__(self, pid: int) -> bool:
|
|
464
|
+
"""Check if product ID exists in registry."""
|
|
465
|
+
return pid in self._products
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# Global registry instance
|
|
469
|
+
_registry = ProductRegistry()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def get_registry() -> ProductRegistry:
|
|
473
|
+
"""Get the global product registry.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Global ProductRegistry instance
|
|
477
|
+
"""
|
|
478
|
+
return _registry
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def get_product(pid: int) -> ProductInfo | None:
|
|
482
|
+
"""Get product info by product ID.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
pid: Product ID
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
ProductInfo if found, None otherwise
|
|
489
|
+
"""
|
|
490
|
+
return _registry.get_product(pid)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def get_device_class_name(pid: int, firmware_version: int | None = None) -> str:
|
|
494
|
+
"""Get appropriate device class name for a product.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
pid: Product ID
|
|
498
|
+
firmware_version: Firmware version (optional)
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Device class name: "TileDevice", "MultiZoneLight", "Light", or "Device"
|
|
502
|
+
"""
|
|
503
|
+
return _registry.get_device_class_name(pid, firmware_version)
|
|
504
|
+
'''
|
|
505
|
+
|
|
506
|
+
return header + products_code + helper_functions
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def update_specs_file(
|
|
510
|
+
products_data: dict[str, Any] | list[dict[str, Any]], specs_path: Path
|
|
511
|
+
) -> None:
|
|
512
|
+
"""Update specs.yml with templates for new products and sort all entries by PID.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
products_data: Parsed products.json data
|
|
516
|
+
specs_path: Path to specs.yml file
|
|
517
|
+
"""
|
|
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
|
+
all_vendors = []
|
|
529
|
+
if isinstance(products_data, list):
|
|
530
|
+
all_vendors = products_data
|
|
531
|
+
else:
|
|
532
|
+
all_vendors = [products_data]
|
|
533
|
+
|
|
534
|
+
# Collect all products with their features for template generation
|
|
535
|
+
new_products = []
|
|
536
|
+
for vendor_data in all_vendors:
|
|
537
|
+
defaults = vendor_data.get("defaults", {})
|
|
538
|
+
default_features = defaults.get("features", {})
|
|
539
|
+
|
|
540
|
+
for product in vendor_data.get("products", []):
|
|
541
|
+
pid = product["pid"]
|
|
542
|
+
all_product_ids.add(pid)
|
|
543
|
+
|
|
544
|
+
# Check if this product needs specs template
|
|
545
|
+
if pid not in existing_specs:
|
|
546
|
+
features = {**default_features, **product.get("features", {})}
|
|
547
|
+
|
|
548
|
+
# Determine if this product needs specs
|
|
549
|
+
is_multizone = features.get("multizone", False)
|
|
550
|
+
is_matrix = features.get("matrix", False)
|
|
551
|
+
|
|
552
|
+
if is_multizone or is_matrix:
|
|
553
|
+
new_products.append(
|
|
554
|
+
{
|
|
555
|
+
"pid": pid,
|
|
556
|
+
"name": product["name"],
|
|
557
|
+
"multizone": is_multizone,
|
|
558
|
+
"matrix": is_matrix,
|
|
559
|
+
"extended_multizone": False, # Will check upgrades
|
|
560
|
+
}
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
# Check for extended multizone in upgrades
|
|
564
|
+
for upgrade in product.get("upgrades", []):
|
|
565
|
+
if upgrade.get("features", {}).get("extended_multizone"):
|
|
566
|
+
new_products[-1]["extended_multizone"] = True
|
|
567
|
+
break
|
|
568
|
+
|
|
569
|
+
if not new_products:
|
|
570
|
+
print("No new multizone or matrix products found - specs.yml is up to date")
|
|
571
|
+
# Still need to sort existing entries
|
|
572
|
+
if existing_specs:
|
|
573
|
+
print("Sorting existing specs entries by product ID...")
|
|
574
|
+
else:
|
|
575
|
+
return
|
|
576
|
+
else:
|
|
577
|
+
print(f"\nFound {len(new_products)} new products that need specs:")
|
|
578
|
+
for product in new_products:
|
|
579
|
+
print(f" PID {product['pid']:>3}: {product['name']}")
|
|
580
|
+
|
|
581
|
+
# Add new products to existing specs with placeholder values
|
|
582
|
+
for product in new_products:
|
|
583
|
+
product_name = product["name"].replace('"', '\\"')
|
|
584
|
+
|
|
585
|
+
if product["multizone"]:
|
|
586
|
+
existing_specs[product["pid"]] = {
|
|
587
|
+
"default_zone_count": 16,
|
|
588
|
+
"min_zone_count": 1,
|
|
589
|
+
"max_zone_count": 16,
|
|
590
|
+
"notes": product_name,
|
|
591
|
+
}
|
|
592
|
+
elif product["matrix"]:
|
|
593
|
+
existing_specs[product["pid"]] = {
|
|
594
|
+
"default_tile_count": 1,
|
|
595
|
+
"min_tile_count": 1,
|
|
596
|
+
"max_tile_count": 1,
|
|
597
|
+
"tile_width": 8,
|
|
598
|
+
"tile_height": 8,
|
|
599
|
+
"notes": product_name,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
# Now regenerate the entire file with sorted entries
|
|
603
|
+
# Separate multizone and matrix products
|
|
604
|
+
multizone_pids = []
|
|
605
|
+
matrix_pids = []
|
|
606
|
+
|
|
607
|
+
for pid, specs in existing_specs.items():
|
|
608
|
+
if "tile_width" in specs or "tile_height" in specs:
|
|
609
|
+
matrix_pids.append(pid)
|
|
610
|
+
elif "default_zone_count" in specs:
|
|
611
|
+
multizone_pids.append(pid)
|
|
612
|
+
|
|
613
|
+
multizone_pids.sort()
|
|
614
|
+
matrix_pids.sort()
|
|
615
|
+
|
|
616
|
+
# Build the new YAML content
|
|
617
|
+
lines = [
|
|
618
|
+
"# LIFX Product Specs and Defaults",
|
|
619
|
+
"# =================================",
|
|
620
|
+
"#",
|
|
621
|
+
"# This file contains product-specific details that are not available in the",
|
|
622
|
+
"# upstream LIFX products.json catalog, such as default zone counts, tile",
|
|
623
|
+
"# configurations, and other device-specific defaults.",
|
|
624
|
+
"#",
|
|
625
|
+
"# These values are used by the emulator to create realistic device",
|
|
626
|
+
"# configurations when specific parameters are not provided by the user.",
|
|
627
|
+
"#",
|
|
628
|
+
"# Format:",
|
|
629
|
+
"# -------",
|
|
630
|
+
"# products:",
|
|
631
|
+
"# <product_id>:",
|
|
632
|
+
"# # For multizone devices",
|
|
633
|
+
"# default_zone_count: <number> # Default zones (e.g., 16)",
|
|
634
|
+
"# min_zone_count: <number> # Minimum zones supported",
|
|
635
|
+
"# max_zone_count: <number> # Maximum zones supported",
|
|
636
|
+
"#",
|
|
637
|
+
"# # For matrix devices (tiles, candles, etc.)",
|
|
638
|
+
"# default_tile_count: <number> # Default number of tiles in chain",
|
|
639
|
+
"# min_tile_count: <number> # Minimum tiles supported",
|
|
640
|
+
"# max_tile_count: <number> # Maximum tiles supported",
|
|
641
|
+
"# tile_width: <number> # Width of each tile in pixels",
|
|
642
|
+
"# tile_height: <number> # Height of each tile in pixels",
|
|
643
|
+
"#",
|
|
644
|
+
"# # Other device-specific defaults",
|
|
645
|
+
'# notes: "<string>" # Notes about product',
|
|
646
|
+
"",
|
|
647
|
+
"products:",
|
|
648
|
+
]
|
|
649
|
+
|
|
650
|
+
# Add multizone section
|
|
651
|
+
if multizone_pids:
|
|
652
|
+
lines.extend(
|
|
653
|
+
[
|
|
654
|
+
" # ========================================",
|
|
655
|
+
" # Multizone Products (Linear Strips)",
|
|
656
|
+
" # ========================================",
|
|
657
|
+
"",
|
|
658
|
+
]
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
for pid in multizone_pids:
|
|
662
|
+
specs = existing_specs[pid]
|
|
663
|
+
# Get product name from comment or notes
|
|
664
|
+
name = specs.get("notes", f"Product {pid}").split(" - ")[0]
|
|
665
|
+
|
|
666
|
+
lines.append(f" {pid}: # {name}")
|
|
667
|
+
lines.append(f" default_zone_count: {specs['default_zone_count']}")
|
|
668
|
+
lines.append(f" min_zone_count: {specs['min_zone_count']}")
|
|
669
|
+
lines.append(f" max_zone_count: {specs['max_zone_count']}")
|
|
670
|
+
|
|
671
|
+
notes = specs.get("notes", "")
|
|
672
|
+
if notes:
|
|
673
|
+
# Escape quotes in notes
|
|
674
|
+
notes_escaped = notes.replace('"', '\\"')
|
|
675
|
+
lines.append(f' notes: "{notes_escaped}"')
|
|
676
|
+
lines.append("")
|
|
677
|
+
|
|
678
|
+
# Add matrix section
|
|
679
|
+
if matrix_pids:
|
|
680
|
+
lines.extend(
|
|
681
|
+
[
|
|
682
|
+
" # ========================================",
|
|
683
|
+
" # Matrix Products (Tiles, Candles, etc.)",
|
|
684
|
+
" # ========================================",
|
|
685
|
+
"",
|
|
686
|
+
]
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
for pid in matrix_pids:
|
|
690
|
+
specs = existing_specs[pid]
|
|
691
|
+
# Get product name from notes
|
|
692
|
+
name = specs.get("notes", f"Product {pid}").split(" - ")[0]
|
|
693
|
+
|
|
694
|
+
lines.append(f" {pid}: # {name}")
|
|
695
|
+
lines.append(f" default_tile_count: {specs['default_tile_count']}")
|
|
696
|
+
lines.append(f" min_tile_count: {specs['min_tile_count']}")
|
|
697
|
+
lines.append(f" max_tile_count: {specs['max_tile_count']}")
|
|
698
|
+
lines.append(f" tile_width: {specs['tile_width']}")
|
|
699
|
+
lines.append(f" tile_height: {specs['tile_height']}")
|
|
700
|
+
|
|
701
|
+
notes = specs.get("notes", "")
|
|
702
|
+
if notes:
|
|
703
|
+
# Escape quotes in notes
|
|
704
|
+
notes_escaped = notes.replace('"', '\\"')
|
|
705
|
+
lines.append(f' notes: "{notes_escaped}"')
|
|
706
|
+
lines.append("")
|
|
707
|
+
|
|
708
|
+
# Write the new file
|
|
709
|
+
with open(specs_path, "w") as f:
|
|
710
|
+
f.write("\n".join(lines))
|
|
711
|
+
|
|
712
|
+
if new_products:
|
|
713
|
+
num_products = len(new_products)
|
|
714
|
+
print(
|
|
715
|
+
f"\n✓ Added {num_products} new product templates "
|
|
716
|
+
f"and sorted all entries by PID"
|
|
717
|
+
)
|
|
718
|
+
print(
|
|
719
|
+
" Please review and update the placeholder values "
|
|
720
|
+
"with actual product specifications"
|
|
721
|
+
)
|
|
722
|
+
else:
|
|
723
|
+
print(f"\n✓ Sorted all {len(existing_specs)} specs entries by product ID")
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def main() -> None:
|
|
727
|
+
"""Main generator entry point."""
|
|
728
|
+
try:
|
|
729
|
+
# Download and parse products from GitHub
|
|
730
|
+
products_data = download_products()
|
|
731
|
+
except Exception as e:
|
|
732
|
+
print(f"Error: Failed to download products.json: {e}", file=sys.stderr)
|
|
733
|
+
sys.exit(1)
|
|
734
|
+
|
|
735
|
+
# Count products for summary
|
|
736
|
+
if isinstance(products_data, list):
|
|
737
|
+
all_products = []
|
|
738
|
+
for vendor in products_data:
|
|
739
|
+
all_products.extend(vendor.get("products", []))
|
|
740
|
+
else:
|
|
741
|
+
all_products = products_data.get("products", [])
|
|
742
|
+
|
|
743
|
+
print(f"Found {len(all_products)} products")
|
|
744
|
+
|
|
745
|
+
# Generate registry.py
|
|
746
|
+
print("\nGenerating registry.py...")
|
|
747
|
+
registry_code = generate_registry_file(products_data)
|
|
748
|
+
|
|
749
|
+
# Determine output path
|
|
750
|
+
registry_path = Path(__file__).parent / "registry.py"
|
|
751
|
+
|
|
752
|
+
with open(registry_path, "w") as f:
|
|
753
|
+
f.write(registry_code)
|
|
754
|
+
|
|
755
|
+
print(f"✓ Generated {registry_path}")
|
|
756
|
+
|
|
757
|
+
# Update specs.yml with templates for new products
|
|
758
|
+
print("\nChecking for new products that need specs...")
|
|
759
|
+
specs_path = Path(__file__).parent / "specs.yml"
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
update_specs_file(products_data, specs_path)
|
|
763
|
+
except Exception as e:
|
|
764
|
+
print(f"Warning: Failed to update specs.yml: {e}", file=sys.stderr)
|
|
765
|
+
print("You can manually add specs for new products")
|
|
766
|
+
|
|
767
|
+
print("\n✓ Generation complete!")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
if __name__ == "__main__":
|
|
771
|
+
main()
|