lifx-emulator 3.1.0__py3-none-any.whl → 4.2.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.
Files changed (42) hide show
  1. {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/METADATA +2 -1
  2. lifx_emulator-4.2.0.dist-info/RECORD +43 -0
  3. lifx_emulator_app/__main__.py +693 -137
  4. lifx_emulator_app/api/__init__.py +0 -4
  5. lifx_emulator_app/api/app.py +122 -16
  6. lifx_emulator_app/api/models.py +32 -1
  7. lifx_emulator_app/api/routers/__init__.py +5 -1
  8. lifx_emulator_app/api/routers/devices.py +64 -10
  9. lifx_emulator_app/api/routers/products.py +42 -0
  10. lifx_emulator_app/api/routers/scenarios.py +55 -52
  11. lifx_emulator_app/api/routers/websocket.py +70 -0
  12. lifx_emulator_app/api/services/__init__.py +21 -4
  13. lifx_emulator_app/api/services/device_service.py +188 -1
  14. lifx_emulator_app/api/services/event_bridge.py +234 -0
  15. lifx_emulator_app/api/services/scenario_service.py +153 -0
  16. lifx_emulator_app/api/services/websocket_manager.py +326 -0
  17. lifx_emulator_app/api/static/_app/env.js +1 -0
  18. lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css +1 -0
  19. lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css +1 -0
  20. lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js +1 -0
  21. lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js +1 -0
  22. lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js +2 -0
  23. lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js +1 -0
  24. lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js +1 -0
  25. lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js +1 -0
  26. lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js +1 -0
  27. lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js +1 -0
  28. lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js +1 -0
  29. lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js +2 -0
  30. lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js +1 -0
  31. lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js +1 -0
  32. lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js +1 -0
  33. lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js +6 -0
  34. lifx_emulator_app/api/static/_app/version.json +1 -0
  35. lifx_emulator_app/api/static/index.html +38 -0
  36. lifx_emulator_app/api/static/robots.txt +3 -0
  37. lifx_emulator_app/config.py +316 -0
  38. lifx_emulator-3.1.0.dist-info/RECORD +0 -19
  39. lifx_emulator_app/api/static/dashboard.js +0 -588
  40. lifx_emulator_app/api/templates/dashboard.html +0 -357
  41. {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
  42. {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/entry_points.txt +0 -0
@@ -1,17 +1,23 @@
1
1
  """CLI entry point for lifx-emulator."""
2
2
 
3
3
  import asyncio
4
+ import json
4
5
  import logging
5
6
  import signal
7
+ import uuid
8
+ import warnings
9
+ import webbrowser
10
+ from pathlib import Path
6
11
  from typing import Annotated
7
12
 
8
13
  import cyclopts
9
- from lifx_emulator.constants import LIFX_UDP_PORT
14
+ import yaml
10
15
  from lifx_emulator.devices import (
11
16
  DEFAULT_STORAGE_DIR,
12
17
  DeviceManager,
13
18
  DevicePersistenceAsyncFile,
14
19
  )
20
+ from lifx_emulator.devices.state_serializer import deserialize_device_state
15
21
  from lifx_emulator.factories import (
16
22
  create_color_light,
17
23
  create_color_temperature_light,
@@ -23,11 +29,25 @@ from lifx_emulator.factories import (
23
29
  create_tile_device,
24
30
  )
25
31
  from lifx_emulator.products.registry import get_registry
32
+ from lifx_emulator.protocol.protocol_types import LightHsbk
26
33
  from lifx_emulator.repositories import DeviceRepository
27
- from lifx_emulator.scenarios import ScenarioPersistenceAsyncFile
34
+ from lifx_emulator.scenarios import (
35
+ HierarchicalScenarioManager,
36
+ ScenarioConfig,
37
+ ScenarioPersistenceAsyncFile,
38
+ )
28
39
  from lifx_emulator.server import EmulatedLifxServer
29
40
  from rich.logging import RichHandler
30
41
 
42
+ from lifx_emulator_app.config import (
43
+ EmulatorConfig,
44
+ ScenarioDefinition,
45
+ ScenariosConfig,
46
+ load_config,
47
+ merge_config,
48
+ resolve_config_path,
49
+ )
50
+
31
51
  app = cyclopts.App(
32
52
  name="lifx-emulator",
33
53
  help="LIFX LAN Protocol Emulator provides virtual LIFX devices for testing",
@@ -35,6 +55,7 @@ app = cyclopts.App(
35
55
  app.register_install_completion_command()
36
56
 
37
57
  # Parameter groups for organizing help output
58
+ config_group = cyclopts.Group.create_ordered("Configuration")
38
59
  server_group = cyclopts.Group.create_ordered("Server Options")
39
60
  storage_group = cyclopts.Group.create_ordered("Storage & Persistence")
40
61
  api_group = cyclopts.Group.create_ordered("HTTP API Server")
@@ -173,8 +194,6 @@ def clear_storage(
173
194
  Clear custom storage directory:
174
195
  lifx-emulator clear-storage --storage-dir /path/to/storage
175
196
  """
176
- from pathlib import Path
177
-
178
197
  # Use default storage directory if not specified
179
198
  storage_path = Path(storage_dir) if storage_dir else DEFAULT_STORAGE_DIR
180
199
 
@@ -208,74 +227,459 @@ def clear_storage(
208
227
  print(f"\nSuccessfully deleted {deleted} device state(s).")
209
228
 
210
229
 
230
+ def _device_state_to_yaml_dict(state_dict: dict) -> dict:
231
+ """Convert a persistent device state dict to a config-file device entry.
232
+
233
+ Only includes fields that are meaningful for initial configuration.
234
+ Skips runtime-only fields (effects, tile positions, framebuffers, etc.).
235
+ """
236
+ entry: dict = {"product_id": state_dict["product"]}
237
+
238
+ entry["serial"] = state_dict["serial"]
239
+
240
+ if state_dict.get("label"):
241
+ entry["label"] = state_dict["label"]
242
+
243
+ if state_dict.get("power_level", 0) != 0:
244
+ entry["power_level"] = state_dict["power_level"]
245
+
246
+ # Color - always include since defaults vary per product
247
+ color = state_dict.get("color")
248
+ if color:
249
+ if isinstance(color, dict):
250
+ entry["color"] = [
251
+ color["hue"],
252
+ color["saturation"],
253
+ color["brightness"],
254
+ color["kelvin"],
255
+ ]
256
+ else:
257
+ # LightHsbk dataclass (already deserialized)
258
+ entry["color"] = [
259
+ color.hue,
260
+ color.saturation,
261
+ color.brightness,
262
+ color.kelvin,
263
+ ]
264
+
265
+ loc_label = state_dict.get("location_label")
266
+ if loc_label and loc_label != "Test Location":
267
+ entry["location"] = loc_label
268
+
269
+ grp_label = state_dict.get("group_label")
270
+ if grp_label and grp_label != "Test Group":
271
+ entry["group"] = grp_label
272
+
273
+ # Multizone zone_colors
274
+ if state_dict.get("has_multizone") and state_dict.get("zone_colors"):
275
+ zone_colors = state_dict["zone_colors"]
276
+ # Check if all zones are the same color - if so, just use color
277
+ all_same = all(
278
+ (
279
+ z.hue == zone_colors[0].hue
280
+ and z.saturation == zone_colors[0].saturation
281
+ and z.brightness == zone_colors[0].brightness
282
+ and z.kelvin == zone_colors[0].kelvin
283
+ )
284
+ if hasattr(z, "hue")
285
+ else (
286
+ z["hue"] == zone_colors[0]["hue"]
287
+ and z["saturation"] == zone_colors[0]["saturation"]
288
+ and z["brightness"] == zone_colors[0]["brightness"]
289
+ and z["kelvin"] == zone_colors[0]["kelvin"]
290
+ )
291
+ for z in zone_colors[1:]
292
+ )
293
+ if not all_same:
294
+ entry["zone_colors"] = [
295
+ [z.hue, z.saturation, z.brightness, z.kelvin]
296
+ if hasattr(z, "hue")
297
+ else [z["hue"], z["saturation"], z["brightness"], z["kelvin"]]
298
+ for z in zone_colors
299
+ ]
300
+ if state_dict.get("zone_count"):
301
+ entry["zone_count"] = state_dict["zone_count"]
302
+
303
+ # Infrared
304
+ if state_dict.get("has_infrared") and state_dict.get("infrared_brightness", 0) != 0:
305
+ entry["infrared_brightness"] = state_dict["infrared_brightness"]
306
+
307
+ # HEV
308
+ if state_dict.get("has_hev"):
309
+ if state_dict.get("hev_cycle_duration_s", 7200) != 7200:
310
+ entry["hev_cycle_duration"] = state_dict["hev_cycle_duration_s"]
311
+ if state_dict.get("hev_indication") is False:
312
+ entry["hev_indication"] = False
313
+
314
+ # Matrix/tile
315
+ if state_dict.get("has_matrix"):
316
+ if state_dict.get("tile_count"):
317
+ entry["tile_count"] = state_dict["tile_count"]
318
+ if state_dict.get("tile_width"):
319
+ entry["tile_width"] = state_dict["tile_width"]
320
+ if state_dict.get("tile_height"):
321
+ entry["tile_height"] = state_dict["tile_height"]
322
+
323
+ return entry
324
+
325
+
326
+ def _scenarios_to_yaml_dict(scenario_file: Path) -> dict | None:
327
+ """Load scenarios.json and convert to config-file format.
328
+
329
+ Returns a dict suitable for the 'scenarios' key in the YAML config,
330
+ or None if no scenario file exists or it's empty.
331
+ """
332
+ if not scenario_file.exists():
333
+ return None
334
+
335
+ try:
336
+ with open(scenario_file) as f:
337
+ data = json.load(f)
338
+ except (json.JSONDecodeError, OSError):
339
+ return None
340
+
341
+ result: dict = {}
342
+
343
+ # Convert global scenario
344
+ global_sc = data.get("global", {})
345
+ if global_sc:
346
+ cleaned_global = _clean_scenario(global_sc)
347
+ if cleaned_global:
348
+ result["global"] = cleaned_global
349
+
350
+ # Convert scoped scenarios
351
+ for scope in ("devices", "types", "locations", "groups"):
352
+ scope_data = data.get(scope, {})
353
+ if scope_data:
354
+ cleaned = {}
355
+ for key, sc in scope_data.items():
356
+ cleaned_sc = _clean_scenario(sc)
357
+ if cleaned_sc:
358
+ cleaned[key] = cleaned_sc
359
+ if cleaned:
360
+ result[scope] = cleaned
361
+
362
+ return result if result else None
363
+
364
+
365
+ def _clean_scenario(sc: dict) -> dict | None:
366
+ """Remove empty/default fields from a scenario dict."""
367
+ cleaned: dict = {}
368
+ for key, val in sc.items():
369
+ if val is None:
370
+ continue
371
+ if isinstance(val, dict | list) and not val:
372
+ continue
373
+ if val is False and key != "send_unhandled":
374
+ continue
375
+ # Convert string keys in drop_packets/response_delays to int
376
+ if key in ("drop_packets", "response_delays") and isinstance(val, dict):
377
+ cleaned[key] = {int(k): v for k, v in val.items()}
378
+ else:
379
+ cleaned[key] = val
380
+ return cleaned if cleaned else None
381
+
382
+
383
+ @app.command
384
+ def export_config(
385
+ storage_dir: str | None = None,
386
+ output: str | None = None,
387
+ no_scenarios: bool = False,
388
+ ) -> None:
389
+ """Export persistent device storage as a YAML config file.
390
+
391
+ Reads saved device state from persistent storage and outputs a valid
392
+ YAML configuration file. This allows migrating from --persistent to a
393
+ config file-based workflow.
394
+
395
+ Args:
396
+ storage_dir: Storage directory to read from. Defaults to ~/.lifx-emulator
397
+ if not specified.
398
+ output: Output file path. If not specified, outputs to stdout.
399
+ no_scenarios: Exclude scenarios from the exported config.
400
+
401
+ Examples:
402
+ Export to stdout:
403
+ lifx-emulator export-config
404
+
405
+ Export to a file:
406
+ lifx-emulator export-config --output my-config.yaml
407
+
408
+ Export without scenarios:
409
+ lifx-emulator export-config --no-scenarios
410
+
411
+ Export from custom storage directory:
412
+ lifx-emulator export-config --storage-dir /path/to/storage
413
+ """
414
+ storage_path = Path(storage_dir) if storage_dir else DEFAULT_STORAGE_DIR
415
+
416
+ if not storage_path.exists():
417
+ print(f"Storage directory not found: {storage_path}")
418
+ return
419
+
420
+ # Find all device state files
421
+ device_files = sorted(storage_path.glob("*.json"))
422
+ device_files = [f for f in device_files if f.name != "scenarios.json"]
423
+
424
+ if not device_files and not (storage_path / "scenarios.json").exists():
425
+ print(f"No persistent device states found in {storage_path}")
426
+ return
427
+
428
+ config: dict = {}
429
+ devices = []
430
+
431
+ for device_file in device_files:
432
+ try:
433
+ with open(device_file) as f:
434
+ state_dict = json.load(f)
435
+ state_dict = deserialize_device_state(state_dict)
436
+ entry = _device_state_to_yaml_dict(state_dict)
437
+ devices.append(entry)
438
+ except Exception as e:
439
+ print(f"Warning: Failed to read {device_file.name}: {e}")
440
+
441
+ if devices:
442
+ config["devices"] = devices
443
+
444
+ # Load scenarios unless excluded
445
+ if not no_scenarios:
446
+ scenario_file = storage_path / "scenarios.json"
447
+ scenarios = _scenarios_to_yaml_dict(scenario_file)
448
+ if scenarios:
449
+ config["scenarios"] = scenarios
450
+
451
+ if not config:
452
+ print("No device states or scenarios found to export.")
453
+ return
454
+
455
+ # Output YAML
456
+ yaml_output = yaml.dump(
457
+ config,
458
+ default_flow_style=None,
459
+ sort_keys=False,
460
+ allow_unicode=True,
461
+ )
462
+
463
+ if output:
464
+ output_path = Path(output)
465
+ with open(output_path, "w") as f:
466
+ f.write(yaml_output)
467
+ print(f"Config exported to {output_path}")
468
+ else:
469
+ print(yaml_output)
470
+
471
+
472
+ def _load_merged_config(**cli_kwargs) -> dict | None:
473
+ """Load config file and merge with CLI overrides.
474
+
475
+ Returns the merged config dict, or None on error.
476
+ """
477
+ config_flag = cli_kwargs.pop("config_flag", None)
478
+
479
+ try:
480
+ config_path = resolve_config_path(config_flag)
481
+ except FileNotFoundError as e:
482
+ print(f"Error: {e}")
483
+ return None
484
+
485
+ file_config = EmulatorConfig()
486
+ if config_path:
487
+ try:
488
+ file_config = load_config(config_path)
489
+ except Exception as e:
490
+ print(f"Error loading config file {config_path}: {e}")
491
+ return None
492
+
493
+ cli_overrides = dict(cli_kwargs)
494
+
495
+ result = merge_config(file_config, cli_overrides)
496
+
497
+ # Carry the devices list from the config (not a CLI parameter)
498
+ # Use `is not None` so explicit `devices: []` is preserved
499
+ if file_config.devices is not None:
500
+ result["devices"] = file_config.devices
501
+
502
+ # Carry scenarios from the config (not a CLI parameter)
503
+ if file_config.scenarios is not None:
504
+ result["scenarios"] = file_config.scenarios
505
+
506
+ # Store config path for logging
507
+ if config_path:
508
+ result["_config_path"] = str(config_path)
509
+
510
+ return result
511
+
512
+
513
+ def _scenario_def_to_core(defn: ScenarioDefinition) -> ScenarioConfig:
514
+ """Convert a config ScenarioDefinition to a core ScenarioConfig."""
515
+ kwargs: dict = {}
516
+ if defn.drop_packets is not None:
517
+ kwargs["drop_packets"] = defn.drop_packets
518
+ if defn.response_delays is not None:
519
+ kwargs["response_delays"] = defn.response_delays
520
+ if defn.malformed_packets is not None:
521
+ kwargs["malformed_packets"] = defn.malformed_packets
522
+ if defn.invalid_field_values is not None:
523
+ kwargs["invalid_field_values"] = defn.invalid_field_values
524
+ if defn.firmware_version is not None:
525
+ kwargs["firmware_version"] = defn.firmware_version
526
+ if defn.partial_responses is not None:
527
+ kwargs["partial_responses"] = defn.partial_responses
528
+ if defn.send_unhandled is not None:
529
+ kwargs["send_unhandled"] = defn.send_unhandled
530
+ return ScenarioConfig(**kwargs)
531
+
532
+
533
+ def _apply_config_scenarios(
534
+ scenarios: ScenariosConfig,
535
+ logger: logging.Logger,
536
+ ) -> HierarchicalScenarioManager:
537
+ """Create a HierarchicalScenarioManager from config file scenarios."""
538
+ manager = HierarchicalScenarioManager()
539
+
540
+ if scenarios.global_scenario:
541
+ manager.set_global_scenario(_scenario_def_to_core(scenarios.global_scenario))
542
+ logger.info("Applied global scenario from config")
543
+
544
+ if scenarios.devices:
545
+ for serial, defn in scenarios.devices.items():
546
+ manager.set_device_scenario(serial, _scenario_def_to_core(defn))
547
+ logger.info(
548
+ "Applied %d device scenario(s) from config",
549
+ len(scenarios.devices),
550
+ )
551
+
552
+ if scenarios.types:
553
+ for type_name, defn in scenarios.types.items():
554
+ manager.set_type_scenario(type_name, _scenario_def_to_core(defn))
555
+ logger.info(
556
+ "Applied %d type scenario(s) from config",
557
+ len(scenarios.types),
558
+ )
559
+
560
+ if scenarios.locations:
561
+ for location, defn in scenarios.locations.items():
562
+ manager.set_location_scenario(location, _scenario_def_to_core(defn))
563
+ logger.info(
564
+ "Applied %d location scenario(s) from config",
565
+ len(scenarios.locations),
566
+ )
567
+
568
+ if scenarios.groups:
569
+ for group, defn in scenarios.groups.items():
570
+ manager.set_group_scenario(group, _scenario_def_to_core(defn))
571
+ logger.info(
572
+ "Applied %d group scenario(s) from config",
573
+ len(scenarios.groups),
574
+ )
575
+
576
+ return manager
577
+
578
+
211
579
  @app.default
212
580
  async def run(
213
581
  *,
582
+ # Configuration
583
+ config: Annotated[str | None, cyclopts.Parameter(group=config_group)] = None,
214
584
  # Server Options
215
- bind: Annotated[str, cyclopts.Parameter(group=server_group)] = "127.0.0.1",
216
- port: Annotated[int, cyclopts.Parameter(group=server_group)] = LIFX_UDP_PORT,
585
+ bind: Annotated[str | None, cyclopts.Parameter(group=server_group)] = None,
586
+ port: Annotated[int | None, cyclopts.Parameter(group=server_group)] = None,
217
587
  verbose: Annotated[
218
- bool, cyclopts.Parameter(negative="", group=server_group)
219
- ] = False,
588
+ bool | None, cyclopts.Parameter(negative="", group=server_group)
589
+ ] = None,
220
590
  # Storage & Persistence
221
591
  persistent: Annotated[
222
- bool, cyclopts.Parameter(negative="", group=storage_group)
223
- ] = False,
592
+ bool | None,
593
+ cyclopts.Parameter(
594
+ negative="",
595
+ group=storage_group,
596
+ help="[DEPRECATED] Use a config file instead. See 'export-config'.",
597
+ ),
598
+ ] = None,
224
599
  persistent_scenarios: Annotated[
225
- bool, cyclopts.Parameter(negative="", group=storage_group)
226
- ] = False,
600
+ bool | None,
601
+ cyclopts.Parameter(
602
+ negative="",
603
+ group=storage_group,
604
+ help="[DEPRECATED] Use a config file instead. See 'export-config'.",
605
+ ),
606
+ ] = None,
227
607
  # HTTP API Server
228
- api: Annotated[bool, cyclopts.Parameter(negative="", group=api_group)] = False,
229
- api_host: Annotated[str, cyclopts.Parameter(group=api_group)] = "127.0.0.1",
230
- api_port: Annotated[int, cyclopts.Parameter(group=api_group)] = 8080,
231
- api_activity: Annotated[bool, cyclopts.Parameter(group=api_group)] = True,
608
+ api: Annotated[
609
+ bool | None, cyclopts.Parameter(negative="", group=api_group)
610
+ ] = None,
611
+ api_host: Annotated[str | None, cyclopts.Parameter(group=api_group)] = None,
612
+ api_port: Annotated[int | None, cyclopts.Parameter(group=api_group)] = None,
613
+ api_activity: Annotated[bool | None, cyclopts.Parameter(group=api_group)] = None,
614
+ browser: Annotated[
615
+ bool | None,
616
+ cyclopts.Parameter(
617
+ negative="",
618
+ group=api_group,
619
+ help="Open dashboard in default browser when API starts.",
620
+ ),
621
+ ] = None,
232
622
  # Device Creation
233
623
  product: Annotated[
234
624
  list[int] | None, cyclopts.Parameter(negative_iterable="", group=device_group)
235
625
  ] = None,
236
- color: Annotated[int, cyclopts.Parameter(group=device_group)] = 1,
237
- color_temperature: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
238
- infrared: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
239
- hev: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
240
- multizone: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
241
- tile: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
242
- switch: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
626
+ color: Annotated[int | None, cyclopts.Parameter(group=device_group)] = None,
627
+ color_temperature: Annotated[
628
+ int | None, cyclopts.Parameter(group=device_group)
629
+ ] = None,
630
+ infrared: Annotated[int | None, cyclopts.Parameter(group=device_group)] = None,
631
+ hev: Annotated[int | None, cyclopts.Parameter(group=device_group)] = None,
632
+ multizone: Annotated[int | None, cyclopts.Parameter(group=device_group)] = None,
633
+ tile: Annotated[int | None, cyclopts.Parameter(group=device_group)] = None,
634
+ switch: Annotated[int | None, cyclopts.Parameter(group=device_group)] = None,
243
635
  # Multizone Options
244
636
  multizone_zones: Annotated[
245
637
  int | None, cyclopts.Parameter(group=multizone_group)
246
638
  ] = None,
247
639
  multizone_extended: Annotated[
248
- bool, cyclopts.Parameter(group=multizone_group)
249
- ] = True,
640
+ bool | None, cyclopts.Parameter(group=multizone_group)
641
+ ] = None,
250
642
  # Tile/Matrix Options
251
643
  tile_count: Annotated[int | None, cyclopts.Parameter(group=tile_group)] = None,
252
644
  tile_width: Annotated[int | None, cyclopts.Parameter(group=tile_group)] = None,
253
645
  tile_height: Annotated[int | None, cyclopts.Parameter(group=tile_group)] = None,
254
646
  # Serial Number Options
255
- serial_prefix: Annotated[str, cyclopts.Parameter(group=serial_group)] = "d073d5",
256
- serial_start: Annotated[int, cyclopts.Parameter(group=serial_group)] = 1,
647
+ serial_prefix: Annotated[str | None, cyclopts.Parameter(group=serial_group)] = None,
648
+ serial_start: Annotated[int | None, cyclopts.Parameter(group=serial_group)] = None,
257
649
  ) -> bool | None:
258
650
  """Start the LIFX emulator with configurable devices.
259
651
 
260
652
  Creates virtual LIFX devices that respond to the LIFX LAN protocol. Supports
261
653
  creating devices by product ID or by device type (color, multizone, tile, etc).
262
- State can optionally be persisted across restarts.
654
+ Settings can be provided via a YAML config file, environment variable, or
655
+ command-line parameters. CLI parameters override config file values.
656
+
657
+ Config file resolution order (first match wins):
658
+ 1. --config path/to/file.yaml (explicit flag)
659
+ 2. LIFX_EMULATOR_CONFIG environment variable
660
+ 3. lifx-emulator.yaml or lifx-emulator.yml in current directory
263
661
 
264
662
  Args:
265
- bind: IP address to bind to.
266
- port: UDP port to listen on.
663
+ config: Path to YAML config file. If not specified, checks
664
+ LIFX_EMULATOR_CONFIG env var, then auto-detects
665
+ lifx-emulator.yaml or lifx-emulator.yml in current directory.
666
+ bind: IP address to bind to. Default: 127.0.0.1.
667
+ port: UDP port to listen on. Default: 56700.
267
668
  verbose: Enable verbose logging showing all packets sent and received.
268
- persistent: Enable persistent storage of device state across restarts.
269
- persistent_scenarios: Enable persistent storage of test scenarios.
270
- Requires --persistent to be enabled.
669
+ persistent: DEPRECATED. Enable persistent storage of device state across
670
+ restarts. Use a config file instead. See 'export-config' command.
671
+ persistent_scenarios: DEPRECATED. Enable persistent storage of test
672
+ scenarios. Use a config file instead. See 'export-config' command.
271
673
  api: Enable HTTP API server for monitoring and runtime device management.
272
- api_host: API server host to bind to.
273
- api_port: API server port.
674
+ api_host: API server host to bind to. Default: 127.0.0.1.
675
+ api_port: API server port. Default: 8080.
274
676
  api_activity: Enable activity logging in API. Disable to reduce traffic
275
- and save UI space on the monitoring dashboard.
677
+ and save UI space on the monitoring dashboard. Default: true.
678
+ browser: Open the monitoring dashboard in the default browser when the
679
+ API server starts. Requires --api. Default: false.
276
680
  product: Create devices by product ID. Can be specified multiple times.
277
681
  Run 'lifx-emulator list-products' to see available products.
278
- color: Number of full-color RGB lights to emulate. Defaults to 1.
682
+ color: Number of full-color RGB lights to emulate.
279
683
  color_temperature: Number of color temperature (white spectrum) lights.
280
684
  infrared: Number of infrared lights with night vision capability.
281
685
  hev: Number of HEV/Clean lights with UV-C germicidal capability.
@@ -284,6 +688,7 @@ async def run(
284
688
  defaults if not specified.
285
689
  multizone_extended: Enable extended multizone support (Beam).
286
690
  Set --no-multizone-extended for basic multizone (Z) devices.
691
+ Default: true.
287
692
  tile: Number of tile/matrix chain devices.
288
693
  switch: Number of LIFX Switch devices (relays, no lighting).
289
694
  tile_count: Number of tiles per device. Uses product defaults if not
@@ -292,11 +697,15 @@ async def run(
292
697
  specified (8 for most devices).
293
698
  tile_height: Height of each tile in zones. Uses product defaults if
294
699
  not specified (8 for most devices).
295
- serial_prefix: Serial number prefix as 6 hex characters.
700
+ serial_prefix: Serial number prefix as 6 hex characters. Default: d073d5.
296
701
  serial_start: Starting serial suffix for auto-incrementing device serials.
702
+ Default: 1.
297
703
 
298
704
  Examples:
299
- Start with default configuration (1 color light):
705
+ Start with a config file:
706
+ lifx-emulator --config my-setup.yaml
707
+
708
+ Auto-detect config file in current directory:
300
709
  lifx-emulator
301
710
 
302
711
  Enable HTTP API server for monitoring:
@@ -311,59 +720,152 @@ async def run(
311
720
  Create diverse devices with API:
312
721
  lifx-emulator --color 2 --multizone 1 --tile 1 --api --verbose
313
722
 
314
- Create only specific device types:
315
- lifx-emulator --color 0 --infrared 3 --hev 2 --switch 2
316
-
317
- Custom serial prefix:
318
- lifx-emulator --serial-prefix cafe00 --color 5
723
+ Override a config file setting:
724
+ lifx-emulator --config setup.yaml --port 56701
319
725
 
320
- Mix products and device types:
321
- lifx-emulator --product 27 --color 2 --multizone 1
322
-
323
- Enable persistent storage:
324
- lifx-emulator --persistent --api
325
726
  """
326
- logger: logging.Logger = _setup_logging(verbose)
727
+ # Load and merge config file with CLI overrides
728
+ cfg = _load_merged_config(
729
+ config_flag=config,
730
+ bind=bind,
731
+ port=port,
732
+ verbose=verbose,
733
+ persistent=persistent,
734
+ persistent_scenarios=persistent_scenarios,
735
+ api=api,
736
+ api_host=api_host,
737
+ api_port=api_port,
738
+ api_activity=api_activity,
739
+ browser=browser,
740
+ products=product,
741
+ color=color,
742
+ color_temperature=color_temperature,
743
+ infrared=infrared,
744
+ hev=hev,
745
+ multizone=multizone,
746
+ tile=tile,
747
+ switch=switch,
748
+ multizone_zones=multizone_zones,
749
+ multizone_extended=multizone_extended,
750
+ tile_count=tile_count,
751
+ tile_width=tile_width,
752
+ tile_height=tile_height,
753
+ serial_prefix=serial_prefix,
754
+ serial_start=serial_start,
755
+ )
756
+ if cfg is None:
757
+ return False
758
+
759
+ # Extract final merged values
760
+ f_bind: str = cfg["bind"]
761
+ f_port: int = cfg["port"]
762
+ f_verbose: bool = cfg["verbose"]
763
+ f_persistent: bool = cfg["persistent"]
764
+ f_persistent_scenarios: bool = cfg["persistent_scenarios"]
765
+ f_api: bool = cfg["api"]
766
+ f_api_host: str = cfg["api_host"]
767
+ f_api_port: int = cfg["api_port"]
768
+ f_api_activity: bool = cfg["api_activity"]
769
+ f_browser: bool = cfg["browser"]
770
+ f_products: list[int] | None = cfg["products"]
771
+ f_color: int = cfg["color"]
772
+ f_color_temperature: int = cfg["color_temperature"]
773
+ f_infrared: int = cfg["infrared"]
774
+ f_hev: int = cfg["hev"]
775
+ f_multizone: int = cfg["multizone"]
776
+ f_tile: int = cfg["tile"]
777
+ f_switch: int = cfg["switch"]
778
+ f_multizone_zones: int | None = cfg["multizone_zones"]
779
+ f_multizone_extended: bool = cfg["multizone_extended"]
780
+ f_tile_count: int | None = cfg["tile_count"]
781
+ f_tile_width: int | None = cfg["tile_width"]
782
+ f_tile_height: int | None = cfg["tile_height"]
783
+ f_serial_prefix: str = cfg["serial_prefix"]
784
+ f_serial_start: int = cfg["serial_start"]
785
+ config_devices: list | None = cfg.get("devices")
786
+ config_scenarios = cfg.get("scenarios")
787
+
788
+ logger: logging.Logger = _setup_logging(f_verbose)
789
+
790
+ # Log config file source if one was used
791
+ config_path = cfg.get("_config_path")
792
+ if config_path:
793
+ logger.info("Loaded config from %s", config_path)
327
794
 
328
795
  # Validate that --persistent-scenarios requires --persistent
329
- if persistent_scenarios and not persistent:
796
+ if f_persistent_scenarios and not f_persistent:
330
797
  logger.error("--persistent-scenarios requires --persistent")
331
798
  return False
332
799
 
800
+ # Deprecation warnings for --persistent / --persistent-scenarios
801
+ if f_persistent:
802
+ warnings.warn(
803
+ "--persistent is deprecated and will be removed in a future "
804
+ "release. Use 'lifx-emulator export-config' to migrate to a "
805
+ "config file.",
806
+ DeprecationWarning,
807
+ stacklevel=1,
808
+ )
809
+ logger.warning(
810
+ "--persistent is deprecated. Use 'lifx-emulator export-config' "
811
+ "to migrate your device state to a config file."
812
+ )
813
+ if f_persistent_scenarios:
814
+ warnings.warn(
815
+ "--persistent-scenarios is deprecated and will be removed in a "
816
+ "future release. Use 'lifx-emulator export-config' to migrate "
817
+ "scenarios to a config file.",
818
+ DeprecationWarning,
819
+ stacklevel=1,
820
+ )
821
+ logger.warning(
822
+ "--persistent-scenarios is deprecated. Use 'lifx-emulator "
823
+ "export-config' to migrate your scenarios to a config file."
824
+ )
825
+
333
826
  # Initialize storage if persistence is enabled
334
- storage = DevicePersistenceAsyncFile() if persistent else None
335
- if persistent and storage:
827
+ storage = DevicePersistenceAsyncFile() if f_persistent else None
828
+ if f_persistent and storage:
336
829
  logger.info("Persistent storage enabled at %s", storage.storage_dir)
337
830
 
338
831
  # Build device list based on parameters
339
832
  devices = []
340
- serial_num = serial_start
833
+ serial_num = f_serial_start
341
834
 
342
- # Helper to generate serials
835
+ # Collect explicit serials from config device definitions
836
+ explicit_serials: set[str] = set()
837
+ if config_devices:
838
+ for dev_def in config_devices:
839
+ if dev_def.serial:
840
+ explicit_serials.add(dev_def.serial.lower())
841
+
842
+ # Helper to generate serials (skipping explicitly assigned ones)
343
843
  def get_serial():
344
844
  nonlocal serial_num
345
- serial = f"{serial_prefix}{serial_num:06x}"
346
- serial_num += 1
347
- return serial
845
+ while True:
846
+ serial = f"{f_serial_prefix}{serial_num:06x}"
847
+ serial_num += 1
848
+ if serial.lower() not in explicit_serials:
849
+ return serial
348
850
 
349
851
  # Check if we should restore devices from persistent storage
350
- # When persistent is enabled, we only create new devices if explicitly requested
351
852
  restore_from_storage = False
352
- if persistent and storage:
853
+ has_any_device_config = (
854
+ bool(f_products)
855
+ or f_color > 0
856
+ or f_color_temperature > 0
857
+ or f_infrared > 0
858
+ or f_hev > 0
859
+ or f_multizone > 0
860
+ or f_tile > 0
861
+ or f_switch > 0
862
+ or config_devices is not None
863
+ )
864
+
865
+ if f_persistent and storage:
353
866
  saved_serials = storage.list_devices()
354
- # Check if user explicitly requested device creation
355
- user_requested_devices = (
356
- product is not None
357
- or color != 1 # color has default value of 1
358
- or color_temperature != 0
359
- or infrared != 0
360
- or hev != 0
361
- or multizone != 0
362
- or tile != 0
363
- )
364
867
 
365
- if saved_serials and not user_requested_devices:
366
- # Restore saved devices
868
+ if saved_serials and not has_any_device_config:
367
869
  restore_from_storage = True
368
870
  logger.info(
369
871
  f"Restoring {len(saved_serials)} device(s) from persistent storage"
@@ -372,15 +874,13 @@ async def run(
372
874
  saved_state = storage.load_device_state(saved_serial)
373
875
  if saved_state:
374
876
  try:
375
- # Create device with the saved serial and product ID
376
877
  device = create_device(
377
878
  saved_state["product"], serial=saved_serial, storage=storage
378
879
  )
379
880
  devices.append(device)
380
881
  except Exception as e:
381
882
  logger.error("Failed to restore device %s: %s", saved_serial, e)
382
- elif not saved_serials and not user_requested_devices:
383
- # Persistent storage is empty and no devices requested
883
+ elif not saved_serials and not has_any_device_config:
384
884
  logger.info(
385
885
  "Persistent storage enabled but empty. Starting with no devices."
386
886
  )
@@ -392,8 +892,8 @@ async def run(
392
892
  # Create new devices if not restoring from storage
393
893
  if not restore_from_storage:
394
894
  # Create devices from product IDs if specified
395
- if product:
396
- for pid in product:
895
+ if f_products:
896
+ for pid in f_products:
397
897
  try:
398
898
  devices.append(
399
899
  create_device(pid, serial=get_serial(), storage=storage)
@@ -404,94 +904,144 @@ async def run(
404
904
  "Run 'lifx-emulator list-products' to see available products"
405
905
  )
406
906
  return
407
- # If using --product, don't create default devices
408
- # Set color to 0 by default
409
- if (
410
- color == 1
411
- and color_temperature == 0
412
- and infrared == 0
413
- and hev == 0
414
- and multizone == 0
415
- and switch == 0
416
- ):
417
- color = 0
418
-
419
- # When persistent is enabled, don't create default devices
420
- # User must explicitly request devices
421
- if (
422
- persistent
423
- and color == 1
424
- and color_temperature == 0
425
- and infrared == 0
426
- and hev == 0
427
- and multizone == 0
428
- and tile == 0
429
- and switch == 0
430
- ):
431
- color = 0
432
907
 
433
908
  # Create color lights
434
- for _ in range(color):
909
+ for _ in range(f_color):
435
910
  devices.append(create_color_light(get_serial(), storage=storage))
436
911
 
437
912
  # Create color temperature lights
438
- for _ in range(color_temperature):
913
+ for _ in range(f_color_temperature):
439
914
  devices.append(
440
915
  create_color_temperature_light(get_serial(), storage=storage)
441
916
  )
442
917
 
443
918
  # Create infrared lights
444
- for _ in range(infrared):
919
+ for _ in range(f_infrared):
445
920
  devices.append(create_infrared_light(get_serial(), storage=storage))
446
921
 
447
922
  # Create HEV lights
448
- for _ in range(hev):
923
+ for _ in range(f_hev):
449
924
  devices.append(create_hev_light(get_serial(), storage=storage))
450
925
 
451
926
  # Create multizone devices (strips/beams)
452
- for _ in range(multizone):
927
+ for _ in range(f_multizone):
453
928
  devices.append(
454
929
  create_multizone_light(
455
930
  get_serial(),
456
- zone_count=multizone_zones,
457
- extended_multizone=multizone_extended,
931
+ zone_count=f_multizone_zones,
932
+ extended_multizone=f_multizone_extended,
458
933
  storage=storage,
459
934
  )
460
935
  )
461
936
 
462
937
  # Create tile devices
463
- for _ in range(tile):
938
+ for _ in range(f_tile):
464
939
  devices.append(
465
940
  create_tile_device(
466
941
  get_serial(),
467
- tile_count=tile_count,
468
- tile_width=tile_width,
469
- tile_height=tile_height,
942
+ tile_count=f_tile_count,
943
+ tile_width=f_tile_width,
944
+ tile_height=f_tile_height,
470
945
  storage=storage,
471
946
  )
472
947
  )
473
948
 
474
949
  # Create switch devices
475
- for _ in range(switch):
950
+ for _ in range(f_switch):
476
951
  devices.append(create_switch(get_serial(), storage=storage))
477
952
 
953
+ # Create devices from per-device definitions in config
954
+ if config_devices:
955
+ # Namespace for deterministic location/group UUIDs
956
+ ns = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
957
+ location_ids: dict[str, bytes] = {}
958
+ group_ids: dict[str, bytes] = {}
959
+
960
+ for dev_def in config_devices:
961
+ try:
962
+ serial = dev_def.serial or get_serial()
963
+ device = create_device(
964
+ dev_def.product_id,
965
+ serial=serial,
966
+ zone_count=dev_def.zone_count,
967
+ tile_count=dev_def.tile_count,
968
+ tile_width=dev_def.tile_width,
969
+ tile_height=dev_def.tile_height,
970
+ storage=storage,
971
+ )
972
+ if dev_def.label:
973
+ device.state.label = dev_def.label
974
+ if dev_def.power_level is not None:
975
+ device.state.power_level = dev_def.power_level
976
+ if dev_def.color is not None:
977
+ device.state.color = LightHsbk(
978
+ hue=dev_def.color.hue,
979
+ saturation=dev_def.color.saturation,
980
+ brightness=dev_def.color.brightness,
981
+ kelvin=dev_def.color.kelvin,
982
+ )
983
+ if dev_def.location is not None:
984
+ loc = dev_def.location
985
+ if loc not in location_ids:
986
+ location_ids[loc] = uuid.uuid5(ns, loc).bytes
987
+ device.state.location_id = location_ids[loc]
988
+ device.state.location_label = loc
989
+ if dev_def.group is not None:
990
+ grp = dev_def.group
991
+ if grp not in group_ids:
992
+ group_ids[grp] = uuid.uuid5(ns, grp).bytes
993
+ device.state.group_id = group_ids[grp]
994
+ device.state.group_label = grp
995
+ if dev_def.zone_colors is not None:
996
+ default_color = LightHsbk(
997
+ hue=0, saturation=0, brightness=0, kelvin=3500
998
+ )
999
+ colors = [
1000
+ LightHsbk(
1001
+ hue=zc.hue,
1002
+ saturation=zc.saturation,
1003
+ brightness=zc.brightness,
1004
+ kelvin=zc.kelvin,
1005
+ )
1006
+ for zc in dev_def.zone_colors
1007
+ ]
1008
+ zone_count = device.state.zone_count
1009
+ if len(colors) < zone_count:
1010
+ colors.extend([default_color] * (zone_count - len(colors)))
1011
+ elif len(colors) > zone_count:
1012
+ colors = colors[:zone_count]
1013
+ device.state.zone_colors = colors
1014
+ if dev_def.infrared_brightness is not None:
1015
+ device.state.infrared_brightness = dev_def.infrared_brightness
1016
+ if dev_def.hev_cycle_duration is not None:
1017
+ device.state.hev_cycle_duration_s = dev_def.hev_cycle_duration
1018
+ if dev_def.hev_indication is not None:
1019
+ device.state.hev_indication = dev_def.hev_indication
1020
+ devices.append(device)
1021
+ except ValueError as e:
1022
+ logger.error("Failed to create device from config: %s", e)
1023
+ logger.info(
1024
+ "Run 'lifx-emulator list-products' to see available products"
1025
+ )
1026
+ return
1027
+
478
1028
  if not devices:
479
- if persistent:
1029
+ if f_persistent:
480
1030
  logger.warning("No devices configured. Server will run with no devices.")
481
1031
  logger.info("Use API (--api) or restart with device flags to add devices.")
482
1032
  else:
483
1033
  logger.error(
484
1034
  "No devices configured. Use --color, --multizone, --tile, --switch, "
485
- "etc. to add devices."
1035
+ "--product, or a config file to add devices."
486
1036
  )
487
1037
  return
488
1038
 
489
1039
  # Set port for all devices
490
1040
  for device in devices:
491
- device.state.port = port
1041
+ device.state.port = f_port
492
1042
 
493
1043
  # Log device information
494
- logger.info("Starting LIFX Emulator on %s:%s", bind, port)
1044
+ logger.info("Starting LIFX Emulator on %s:%s", f_bind, f_port)
495
1045
  logger.info("Created %s emulated device(s):", len(devices))
496
1046
  for device in devices:
497
1047
  label = device.state.label
@@ -503,35 +1053,45 @@ async def run(
503
1053
  device_repository = DeviceRepository()
504
1054
  device_manager = DeviceManager(device_repository)
505
1055
 
506
- # Load scenarios from storage if persistence is enabled
1056
+ # Load scenarios from storage or config
507
1057
  scenario_manager = None
508
1058
  scenario_storage = None
509
- if persistent_scenarios:
1059
+ if f_persistent_scenarios:
510
1060
  scenario_storage = ScenarioPersistenceAsyncFile()
511
1061
  scenario_manager = await scenario_storage.load()
512
1062
  logger.info("Loaded scenarios from persistent storage")
513
1063
 
1064
+ # Apply scenarios from config file (only if not using persistent scenarios)
1065
+ if config_scenarios and not f_persistent_scenarios:
1066
+ scenario_manager = _apply_config_scenarios(config_scenarios, logger)
1067
+
514
1068
  # Start LIFX server
515
1069
  server = EmulatedLifxServer(
516
1070
  devices,
517
1071
  device_manager,
518
- bind,
519
- port,
520
- track_activity=api_activity if api else False,
1072
+ f_bind,
1073
+ f_port,
1074
+ track_activity=f_api_activity if f_api else False,
521
1075
  storage=storage,
522
1076
  scenario_manager=scenario_manager,
523
- persist_scenarios=persistent_scenarios,
1077
+ persist_scenarios=f_persistent_scenarios,
524
1078
  scenario_storage=scenario_storage,
525
1079
  )
526
1080
  await server.start()
527
1081
 
528
1082
  # Start API server if enabled
529
1083
  api_task = None
530
- if api:
1084
+ if f_api:
531
1085
  from lifx_emulator_app.api import run_api_server
532
1086
 
533
- logger.info("Starting HTTP API server on http://%s:%s", api_host, api_port)
534
- api_task = asyncio.create_task(run_api_server(server, api_host, api_port))
1087
+ logger.info("Starting HTTP API server on http://%s:%s", f_api_host, f_api_port)
1088
+ api_task = asyncio.create_task(run_api_server(server, f_api_host, f_api_port))
1089
+
1090
+ # Open browser if requested
1091
+ if f_browser:
1092
+ dashboard_url = f"http://{f_api_host}:{f_api_port}"
1093
+ logger.info("Opening dashboard in browser: %s", dashboard_url)
1094
+ webbrowser.open(dashboard_url)
535
1095
 
536
1096
  # Set up graceful shutdown on signals
537
1097
  shutdown_event = asyncio.Event()
@@ -539,29 +1099,26 @@ async def run(
539
1099
 
540
1100
  def signal_handler(signum, frame):
541
1101
  """Handle shutdown signals gracefully (thread-safe for asyncio)."""
542
- # Use call_soon_threadsafe to safely set event from signal handler
543
1102
  loop.call_soon_threadsafe(shutdown_event.set)
544
1103
 
545
- # Register signal handlers for graceful shutdown
546
- # Use signal.signal() instead of loop.add_signal_handler() for Windows compatibility
547
1104
  signal.signal(signal.SIGTERM, signal_handler)
548
1105
  signal.signal(signal.SIGINT, signal_handler)
549
1106
 
550
- # On Windows, also handle SIGBREAK
551
1107
  sigbreak = getattr(signal, "SIGBREAK", None)
552
1108
  if sigbreak is not None:
553
1109
  signal.signal(sigbreak, signal_handler)
554
1110
 
555
1111
  try:
556
- if api:
1112
+ if f_api:
557
1113
  logger.info(
558
- f"LIFX server running on {bind}:{port}, API server on http://{api_host}:{api_port}"
1114
+ f"LIFX server running on {f_bind}:{f_port}, "
1115
+ f"API server on http://{f_api_host}:{f_api_port}"
559
1116
  )
560
1117
  logger.info(
561
- f"Open http://{api_host}:{api_port} in your browser "
1118
+ f"Open http://{f_api_host}:{f_api_port} in your browser "
562
1119
  "to view the monitoring dashboard"
563
1120
  )
564
- elif verbose:
1121
+ elif f_verbose:
565
1122
  logger.info(
566
1123
  "Server running with verbose packet logging... Press Ctrl+C to stop"
567
1124
  )
@@ -570,11 +1127,10 @@ async def run(
570
1127
  "Server running... Press Ctrl+C to stop (use --verbose to see packets)"
571
1128
  )
572
1129
 
573
- await shutdown_event.wait() # Wait for shutdown signal
1130
+ await shutdown_event.wait()
574
1131
  finally:
575
1132
  logger.info("Shutting down server...")
576
1133
 
577
- # Shutdown storage first to flush pending writes
578
1134
  if storage:
579
1135
  await storage.shutdown()
580
1136