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