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.
Files changed (68) hide show
  1. lifx_emulator-3.0.1.dist-info/METADATA +102 -0
  2. lifx_emulator-3.0.1.dist-info/RECORD +18 -0
  3. lifx_emulator-3.0.1.dist-info/entry_points.txt +2 -0
  4. lifx_emulator_app/__init__.py +10 -0
  5. {lifx_emulator → lifx_emulator_app}/__main__.py +2 -3
  6. {lifx_emulator → lifx_emulator_app}/api/__init__.py +1 -1
  7. {lifx_emulator → lifx_emulator_app}/api/app.py +3 -3
  8. {lifx_emulator → lifx_emulator_app}/api/mappers/__init__.py +1 -1
  9. {lifx_emulator → lifx_emulator_app}/api/mappers/device_mapper.py +1 -1
  10. {lifx_emulator → lifx_emulator_app}/api/models.py +1 -2
  11. lifx_emulator_app/api/routers/__init__.py +11 -0
  12. {lifx_emulator → lifx_emulator_app}/api/routers/devices.py +2 -2
  13. {lifx_emulator → lifx_emulator_app}/api/routers/monitoring.py +1 -1
  14. {lifx_emulator → lifx_emulator_app}/api/routers/scenarios.py +1 -1
  15. lifx_emulator_app/api/services/__init__.py +8 -0
  16. {lifx_emulator → lifx_emulator_app}/api/services/device_service.py +3 -2
  17. lifx_emulator/__init__.py +0 -31
  18. lifx_emulator/api/routers/__init__.py +0 -11
  19. lifx_emulator/api/services/__init__.py +0 -8
  20. lifx_emulator/constants.py +0 -33
  21. lifx_emulator/devices/__init__.py +0 -37
  22. lifx_emulator/devices/device.py +0 -395
  23. lifx_emulator/devices/manager.py +0 -256
  24. lifx_emulator/devices/observers.py +0 -139
  25. lifx_emulator/devices/persistence.py +0 -308
  26. lifx_emulator/devices/state_restorer.py +0 -259
  27. lifx_emulator/devices/state_serializer.py +0 -157
  28. lifx_emulator/devices/states.py +0 -381
  29. lifx_emulator/factories/__init__.py +0 -39
  30. lifx_emulator/factories/builder.py +0 -375
  31. lifx_emulator/factories/default_config.py +0 -158
  32. lifx_emulator/factories/factory.py +0 -252
  33. lifx_emulator/factories/firmware_config.py +0 -77
  34. lifx_emulator/factories/serial_generator.py +0 -82
  35. lifx_emulator/handlers/__init__.py +0 -39
  36. lifx_emulator/handlers/base.py +0 -49
  37. lifx_emulator/handlers/device_handlers.py +0 -322
  38. lifx_emulator/handlers/light_handlers.py +0 -503
  39. lifx_emulator/handlers/multizone_handlers.py +0 -249
  40. lifx_emulator/handlers/registry.py +0 -110
  41. lifx_emulator/handlers/tile_handlers.py +0 -488
  42. lifx_emulator/products/__init__.py +0 -28
  43. lifx_emulator/products/generator.py +0 -1079
  44. lifx_emulator/products/registry.py +0 -1530
  45. lifx_emulator/products/specs.py +0 -284
  46. lifx_emulator/products/specs.yml +0 -386
  47. lifx_emulator/protocol/__init__.py +0 -1
  48. lifx_emulator/protocol/base.py +0 -446
  49. lifx_emulator/protocol/const.py +0 -8
  50. lifx_emulator/protocol/generator.py +0 -1384
  51. lifx_emulator/protocol/header.py +0 -159
  52. lifx_emulator/protocol/packets.py +0 -1351
  53. lifx_emulator/protocol/protocol_types.py +0 -817
  54. lifx_emulator/protocol/serializer.py +0 -379
  55. lifx_emulator/repositories/__init__.py +0 -22
  56. lifx_emulator/repositories/device_repository.py +0 -155
  57. lifx_emulator/repositories/storage_backend.py +0 -107
  58. lifx_emulator/scenarios/__init__.py +0 -22
  59. lifx_emulator/scenarios/manager.py +0 -322
  60. lifx_emulator/scenarios/models.py +0 -112
  61. lifx_emulator/scenarios/persistence.py +0 -241
  62. lifx_emulator/server.py +0 -464
  63. lifx_emulator-2.4.0.dist-info/METADATA +0 -107
  64. lifx_emulator-2.4.0.dist-info/RECORD +0 -62
  65. lifx_emulator-2.4.0.dist-info/entry_points.txt +0 -2
  66. lifx_emulator-2.4.0.dist-info/licenses/LICENSE +0 -35
  67. {lifx_emulator-2.4.0.dist-info → lifx_emulator-3.0.1.dist-info}/WHEEL +0 -0
  68. {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()