lifx-emulator 3.1.0__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.
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.0.0.dist-info}/METADATA +1 -1
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.0.0.dist-info}/RECORD +6 -5
- lifx_emulator_app/__main__.py +660 -132
- lifx_emulator_app/config.py +314 -0
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.0.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.0.0.dist-info}/entry_points.txt +0 -0
lifx_emulator_app/__main__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)] =
|
|
216
|
-
port: Annotated[int, cyclopts.Parameter(group=server_group)] =
|
|
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
|
-
] =
|
|
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
|
-
] =
|
|
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
|
-
] =
|
|
594
|
+
bool | None, cyclopts.Parameter(negative="", group=storage_group)
|
|
595
|
+
] = None,
|
|
227
596
|
# HTTP API Server
|
|
228
|
-
api: Annotated[
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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)] =
|
|
237
|
-
color_temperature: Annotated[
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
] =
|
|
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)] =
|
|
256
|
-
serial_start: Annotated[int, cyclopts.Parameter(group=serial_group)] =
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
315
|
-
lifx-emulator --
|
|
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
|
-
|
|
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
|
|
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
|
|
335
|
-
if
|
|
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 =
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
396
|
-
for pid in
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
905
|
+
for _ in range(f_multizone):
|
|
453
906
|
devices.append(
|
|
454
907
|
create_multizone_light(
|
|
455
908
|
get_serial(),
|
|
456
|
-
zone_count=
|
|
457
|
-
extended_multizone=
|
|
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(
|
|
916
|
+
for _ in range(f_tile):
|
|
464
917
|
devices.append(
|
|
465
918
|
create_tile_device(
|
|
466
919
|
get_serial(),
|
|
467
|
-
tile_count=
|
|
468
|
-
tile_width=
|
|
469
|
-
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(
|
|
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
|
|
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
|
-
"
|
|
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 =
|
|
1019
|
+
device.state.port = f_port
|
|
492
1020
|
|
|
493
1021
|
# Log device information
|
|
494
|
-
logger.info("Starting LIFX Emulator on %s:%s",
|
|
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
|
|
1034
|
+
# Load scenarios from storage or config
|
|
507
1035
|
scenario_manager = None
|
|
508
1036
|
scenario_storage = None
|
|
509
|
-
if
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
track_activity=
|
|
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=
|
|
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
|
|
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",
|
|
534
|
-
api_task = asyncio.create_task(run_api_server(server,
|
|
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
|
|
1084
|
+
if f_api:
|
|
557
1085
|
logger.info(
|
|
558
|
-
f"LIFX server running on {
|
|
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://{
|
|
1090
|
+
f"Open http://{f_api_host}:{f_api_port} in your browser "
|
|
562
1091
|
"to view the monitoring dashboard"
|
|
563
1092
|
)
|
|
564
|
-
elif
|
|
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()
|
|
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
|
|