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.
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/METADATA +2 -1
- lifx_emulator-4.2.0.dist-info/RECORD +43 -0
- lifx_emulator_app/__main__.py +693 -137
- lifx_emulator_app/api/__init__.py +0 -4
- lifx_emulator_app/api/app.py +122 -16
- lifx_emulator_app/api/models.py +32 -1
- lifx_emulator_app/api/routers/__init__.py +5 -1
- lifx_emulator_app/api/routers/devices.py +64 -10
- lifx_emulator_app/api/routers/products.py +42 -0
- lifx_emulator_app/api/routers/scenarios.py +55 -52
- lifx_emulator_app/api/routers/websocket.py +70 -0
- lifx_emulator_app/api/services/__init__.py +21 -4
- lifx_emulator_app/api/services/device_service.py +188 -1
- lifx_emulator_app/api/services/event_bridge.py +234 -0
- lifx_emulator_app/api/services/scenario_service.py +153 -0
- lifx_emulator_app/api/services/websocket_manager.py +326 -0
- lifx_emulator_app/api/static/_app/env.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/assets/0.DOQLX7EM.css +1 -0
- lifx_emulator_app/api/static/_app/immutable/assets/2.CU0O2Xrb.css +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BORyfda6.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BTLkiQR5.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/BaoxLdOF.js +2 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/Binc8JbE.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/CDSQEL5N.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/DfIkQq0Y.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/MAGDeS2Z.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/N3z8axFy.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/chunks/yhjkpkcN.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/entry/app.Dhwm664s.js +2 -0
- lifx_emulator_app/api/static/_app/immutable/entry/start.Nqz6UJJT.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/0.CPncm6RP.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/1.x-f3libw.js +1 -0
- lifx_emulator_app/api/static/_app/immutable/nodes/2.BP5Yvqf4.js +6 -0
- lifx_emulator_app/api/static/_app/version.json +1 -0
- lifx_emulator_app/api/static/index.html +38 -0
- lifx_emulator_app/api/static/robots.txt +3 -0
- lifx_emulator_app/config.py +316 -0
- lifx_emulator-3.1.0.dist-info/RECORD +0 -19
- lifx_emulator_app/api/static/dashboard.js +0 -588
- lifx_emulator_app/api/templates/dashboard.html +0 -357
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/WHEEL +0 -0
- {lifx_emulator-3.1.0.dist-info → lifx_emulator-4.2.0.dist-info}/entry_points.txt +0 -0
lifx_emulator_app/__main__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)] =
|
|
216
|
-
port: Annotated[int, cyclopts.Parameter(group=server_group)] =
|
|
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
|
-
] =
|
|
588
|
+
bool | None, cyclopts.Parameter(negative="", group=server_group)
|
|
589
|
+
] = None,
|
|
220
590
|
# Storage & Persistence
|
|
221
591
|
persistent: Annotated[
|
|
222
|
-
bool
|
|
223
|
-
|
|
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
|
|
226
|
-
|
|
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[
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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)] =
|
|
237
|
-
color_temperature: Annotated[
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
] =
|
|
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)] =
|
|
256
|
-
serial_start: Annotated[int, cyclopts.Parameter(group=serial_group)] =
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
315
|
-
lifx-emulator --
|
|
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
|
-
|
|
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
|
|
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
|
|
335
|
-
if
|
|
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 =
|
|
833
|
+
serial_num = f_serial_start
|
|
341
834
|
|
|
342
|
-
#
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
396
|
-
for pid in
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
927
|
+
for _ in range(f_multizone):
|
|
453
928
|
devices.append(
|
|
454
929
|
create_multizone_light(
|
|
455
930
|
get_serial(),
|
|
456
|
-
zone_count=
|
|
457
|
-
extended_multizone=
|
|
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(
|
|
938
|
+
for _ in range(f_tile):
|
|
464
939
|
devices.append(
|
|
465
940
|
create_tile_device(
|
|
466
941
|
get_serial(),
|
|
467
|
-
tile_count=
|
|
468
|
-
tile_width=
|
|
469
|
-
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(
|
|
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
|
|
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
|
-
"
|
|
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 =
|
|
1041
|
+
device.state.port = f_port
|
|
492
1042
|
|
|
493
1043
|
# Log device information
|
|
494
|
-
logger.info("Starting LIFX Emulator on %s:%s",
|
|
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
|
|
1056
|
+
# Load scenarios from storage or config
|
|
507
1057
|
scenario_manager = None
|
|
508
1058
|
scenario_storage = None
|
|
509
|
-
if
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
track_activity=
|
|
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=
|
|
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
|
|
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",
|
|
534
|
-
api_task = asyncio.create_task(run_api_server(server,
|
|
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
|
|
1112
|
+
if f_api:
|
|
557
1113
|
logger.info(
|
|
558
|
-
f"LIFX server running on {
|
|
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://{
|
|
1118
|
+
f"Open http://{f_api_host}:{f_api_port} in your browser "
|
|
562
1119
|
"to view the monitoring dashboard"
|
|
563
1120
|
)
|
|
564
|
-
elif
|
|
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()
|
|
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
|
|