lifx-emulator 1.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.
Files changed (40) hide show
  1. lifx_emulator/__init__.py +31 -0
  2. lifx_emulator/__main__.py +607 -0
  3. lifx_emulator/api.py +1825 -0
  4. lifx_emulator/async_storage.py +308 -0
  5. lifx_emulator/constants.py +33 -0
  6. lifx_emulator/device.py +750 -0
  7. lifx_emulator/device_states.py +114 -0
  8. lifx_emulator/factories.py +380 -0
  9. lifx_emulator/handlers/__init__.py +39 -0
  10. lifx_emulator/handlers/base.py +49 -0
  11. lifx_emulator/handlers/device_handlers.py +340 -0
  12. lifx_emulator/handlers/light_handlers.py +372 -0
  13. lifx_emulator/handlers/multizone_handlers.py +249 -0
  14. lifx_emulator/handlers/registry.py +110 -0
  15. lifx_emulator/handlers/tile_handlers.py +309 -0
  16. lifx_emulator/observers.py +139 -0
  17. lifx_emulator/products/__init__.py +28 -0
  18. lifx_emulator/products/generator.py +771 -0
  19. lifx_emulator/products/registry.py +1446 -0
  20. lifx_emulator/products/specs.py +242 -0
  21. lifx_emulator/products/specs.yml +327 -0
  22. lifx_emulator/protocol/__init__.py +1 -0
  23. lifx_emulator/protocol/base.py +334 -0
  24. lifx_emulator/protocol/const.py +8 -0
  25. lifx_emulator/protocol/generator.py +1371 -0
  26. lifx_emulator/protocol/header.py +159 -0
  27. lifx_emulator/protocol/packets.py +1351 -0
  28. lifx_emulator/protocol/protocol_types.py +844 -0
  29. lifx_emulator/protocol/serializer.py +379 -0
  30. lifx_emulator/scenario_manager.py +402 -0
  31. lifx_emulator/scenario_persistence.py +206 -0
  32. lifx_emulator/server.py +482 -0
  33. lifx_emulator/state_restorer.py +259 -0
  34. lifx_emulator/state_serializer.py +130 -0
  35. lifx_emulator/storage_protocol.py +100 -0
  36. lifx_emulator-1.0.0.dist-info/METADATA +445 -0
  37. lifx_emulator-1.0.0.dist-info/RECORD +40 -0
  38. lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
  39. lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
  40. lifx_emulator-1.0.0.dist-info/licenses/LICENSE +35 -0
@@ -0,0 +1,31 @@
1
+ """LIFX Emulator
2
+
3
+ A comprehensive LIFX emulator for testing LIFX LAN protocol libraries.
4
+ Implements the binary UDP protocol documented at https://lan.developer.lifx.com
5
+ """
6
+
7
+ from importlib.metadata import version as get_version
8
+
9
+ from lifx_emulator.device import EmulatedLifxDevice
10
+ from lifx_emulator.factories import (
11
+ create_color_light,
12
+ create_color_temperature_light,
13
+ create_hev_light,
14
+ create_infrared_light,
15
+ create_multizone_light,
16
+ create_tile_device,
17
+ )
18
+ from lifx_emulator.server import EmulatedLifxServer
19
+
20
+ __version__ = get_version("lifx_emulator")
21
+
22
+ __all__ = [
23
+ "EmulatedLifxServer",
24
+ "EmulatedLifxDevice",
25
+ "create_color_light",
26
+ "create_color_temperature_light",
27
+ "create_hev_light",
28
+ "create_infrared_light",
29
+ "create_multizone_light",
30
+ "create_tile_device",
31
+ ]
@@ -0,0 +1,607 @@
1
+ """CLI entry point for lifx-emulator."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import signal
6
+ from typing import Annotated
7
+
8
+ import cyclopts
9
+ from rich.logging import RichHandler
10
+
11
+ from lifx_emulator.async_storage import AsyncDeviceStorage
12
+ from lifx_emulator.constants import LIFX_UDP_PORT
13
+ from lifx_emulator.factories import (
14
+ create_color_light,
15
+ create_color_temperature_light,
16
+ create_device,
17
+ create_hev_light,
18
+ create_infrared_light,
19
+ create_multizone_light,
20
+ create_tile_device,
21
+ )
22
+ from lifx_emulator.products.registry import ProductInfo, get_registry
23
+ from lifx_emulator.server import EmulatedLifxServer
24
+
25
+ app = cyclopts.App(
26
+ name="lifx-emulator",
27
+ help="LIFX LAN Protocol Emulator provides virtual LIFX devices for testing",
28
+ )
29
+ app.register_install_completion_command()
30
+
31
+ # Parameter groups for organizing help output
32
+ server_group = cyclopts.Group.create_ordered("Server Options")
33
+ storage_group = cyclopts.Group.create_ordered("Storage & Persistence")
34
+ api_group = cyclopts.Group.create_ordered("HTTP API Server")
35
+ device_group = cyclopts.Group.create_ordered("Device Creation")
36
+ multizone_group = cyclopts.Group.create_ordered("Multizone Options")
37
+ tile_group = cyclopts.Group.create_ordered("Tile/Matrix Options")
38
+ serial_group = cyclopts.Group.create_ordered("Serial Number Options")
39
+
40
+
41
+ def _setup_logging(verbose: bool) -> logging.Logger:
42
+ """Configure logging based on verbosity level."""
43
+ log_format = "%(message)s"
44
+ level = logging.DEBUG if verbose else logging.INFO
45
+ logging.basicConfig(format=log_format, handlers=[RichHandler()], level=level)
46
+
47
+ _logger = logging.getLogger(__package__)
48
+
49
+ if verbose:
50
+ _logger.debug("Verbose logging enabled")
51
+
52
+ return _logger
53
+
54
+
55
+ def _format_capabilities(device) -> str:
56
+ """Format device capabilities as a human-readable string."""
57
+ capabilities = []
58
+ if device.state.has_color:
59
+ capabilities.append("color")
60
+ elif not device.state.has_color:
61
+ capabilities.append("white-only")
62
+ if device.state.has_infrared:
63
+ capabilities.append("infrared")
64
+ if device.state.has_hev:
65
+ capabilities.append("HEV")
66
+ if device.state.has_multizone:
67
+ if device.state.zone_count > 16:
68
+ capabilities.append(f"extended-multizone({device.state.zone_count})")
69
+ else:
70
+ capabilities.append(f"multizone({device.state.zone_count})")
71
+ if device.state.has_matrix:
72
+ total_zones = device.state.tile_width * device.state.tile_height
73
+ if total_zones > 64:
74
+ dim = f"{device.state.tile_width}x{device.state.tile_height}"
75
+ capabilities.append(f"tile({device.state.tile_count}x {dim})")
76
+ else:
77
+ capabilities.append(f"tile({device.state.tile_count})")
78
+ return ", ".join(capabilities)
79
+
80
+
81
+ def _format_product_capabilities(product: ProductInfo) -> str:
82
+ """Format product capabilities as a human-readable string."""
83
+ caps = []
84
+
85
+ # Determine base light type
86
+ if product.has_relays:
87
+ # Devices with relays are switches, not lights
88
+ caps.append("switch")
89
+ elif product.has_color:
90
+ caps.append("full color")
91
+ else:
92
+ # Check temperature range to determine white light type
93
+ if product.temperature_range:
94
+ if product.temperature_range.min != product.temperature_range.max:
95
+ caps.append("color temperature")
96
+ else:
97
+ caps.append("brightness only")
98
+ else:
99
+ # No temperature range info, assume basic brightness
100
+ caps.append("brightness only")
101
+
102
+ # Add additional capabilities
103
+ if product.has_infrared:
104
+ caps.append("infrared")
105
+ if product.has_multizone:
106
+ caps.append("multizone")
107
+ if product.has_extended_multizone:
108
+ caps.append("extended-multizone")
109
+ if product.has_matrix:
110
+ caps.append("matrix")
111
+ if product.has_hev:
112
+ caps.append("HEV")
113
+ if product.has_chain:
114
+ caps.append("chain")
115
+ if product.has_buttons and not product.has_relays:
116
+ # Only show buttons if not already identified as switch
117
+ caps.append("buttons")
118
+
119
+ return ", ".join(caps) if caps else "unknown"
120
+
121
+
122
+ @app.command
123
+ def list_products(
124
+ filter_type: str | None = None,
125
+ ) -> None:
126
+ """List all available LIFX products from the registry.
127
+
128
+ Products are sorted by product ID and display their name and supported
129
+ features including color, multizone, matrix, HEV, and infrared capabilities.
130
+
131
+ Args:
132
+ filter_type: Filter by capability (color, multizone, matrix, hev, infrared).
133
+ If not specified, lists all products.
134
+
135
+ Examples:
136
+ List all products:
137
+ lifx-emulator list-products
138
+
139
+ List only multizone products:
140
+ lifx-emulator list-products --filter-type multizone
141
+
142
+ List only matrix/tile products:
143
+ lifx-emulator list-products --filter-type matrix
144
+ """
145
+ registry = get_registry()
146
+
147
+ # Get all products sorted by PID
148
+ all_products = []
149
+ for pid in sorted(registry._products.keys()):
150
+ product = registry._products[pid]
151
+ # Apply filter if specified
152
+ if filter_type:
153
+ filter_lower = filter_type.lower()
154
+ if filter_lower == "color" and not product.has_color:
155
+ continue
156
+ if filter_lower == "multizone" and not product.has_multizone:
157
+ continue
158
+ if filter_lower == "matrix" and not product.has_matrix:
159
+ continue
160
+ if filter_lower == "hev" and not product.has_hev:
161
+ continue
162
+ if filter_lower == "infrared" and not product.has_infrared:
163
+ continue
164
+ all_products.append(product)
165
+
166
+ if not all_products:
167
+ if filter_type:
168
+ print(f"No products found with filter: {filter_type}")
169
+ else:
170
+ print("No products in registry")
171
+ return
172
+
173
+ print(f"\nLIFX Product Registry ({len(all_products)} products)\n")
174
+ print(f"{'PID':>4} │ {'Product Name':<40} │ {'Capabilities'}")
175
+ print("─" * 4 + "─┼─" + "─" * 40 + "─┼─" + "─" * 40)
176
+
177
+ for product in all_products:
178
+ caps = _format_product_capabilities(product)
179
+ print(f"{product.pid:>4} │ {product.name:<40} │ {caps}")
180
+
181
+ print()
182
+ print("Use --product <PID> to emulate a specific product")
183
+ print(f"Example: lifx-emulator --product {all_products[0].pid}")
184
+
185
+
186
+ @app.command
187
+ def clear_storage(
188
+ storage_dir: str | None = None,
189
+ yes: bool = False,
190
+ ) -> None:
191
+ """Clear all persistent device state from storage.
192
+
193
+ Deletes all saved device state files from the persistent storage directory.
194
+ Use this when you want to start fresh without any saved devices. A confirmation
195
+ prompt is shown unless --yes is specified.
196
+
197
+ Args:
198
+ storage_dir: Storage directory to clear. Defaults to ~/.lifx-emulator if
199
+ not specified.
200
+ yes: Skip confirmation prompt and delete immediately.
201
+
202
+ Examples:
203
+ Clear default storage location (with confirmation):
204
+ lifx-emulator clear-storage
205
+
206
+ Clear without confirmation prompt:
207
+ lifx-emulator clear-storage --yes
208
+
209
+ Clear custom storage directory:
210
+ lifx-emulator clear-storage --storage-dir /path/to/storage
211
+ """
212
+ from pathlib import Path
213
+
214
+ from lifx_emulator.async_storage import DEFAULT_STORAGE_DIR, AsyncDeviceStorage
215
+
216
+ # Use default storage directory if not specified
217
+ storage_path = Path(storage_dir) if storage_dir else DEFAULT_STORAGE_DIR
218
+
219
+ # Create storage instance
220
+ storage = AsyncDeviceStorage(storage_path)
221
+
222
+ # List devices
223
+ devices = storage.list_devices()
224
+
225
+ if not devices:
226
+ print(f"No persistent device states found in {storage_path}")
227
+ return
228
+
229
+ # Show what will be deleted
230
+ print(f"\nFound {len(devices)} persistent device state(s) in {storage_path}:")
231
+ for serial in devices:
232
+ print(f" • {serial}")
233
+
234
+ # Confirm deletion
235
+ if not yes:
236
+ print(
237
+ f"\nThis will permanently delete all {len(devices)} device state file(s)."
238
+ )
239
+ response = input("Are you sure you want to continue? [y/N] ")
240
+ if response.lower() not in ("y", "yes"):
241
+ print("Operation cancelled.")
242
+ return
243
+
244
+ # Delete all device states
245
+ deleted = storage.delete_all_device_states()
246
+ print(f"\nSuccessfully deleted {deleted} device state(s).")
247
+
248
+
249
+ @app.default
250
+ async def run(
251
+ *,
252
+ # Server Options
253
+ bind: Annotated[str, cyclopts.Parameter(group=server_group)] = "127.0.0.1",
254
+ port: Annotated[int, cyclopts.Parameter(group=server_group)] = LIFX_UDP_PORT,
255
+ verbose: Annotated[
256
+ bool, cyclopts.Parameter(negative="", group=server_group)
257
+ ] = False,
258
+ # Storage & Persistence
259
+ persistent: Annotated[
260
+ bool, cyclopts.Parameter(negative="", group=storage_group)
261
+ ] = False,
262
+ persistent_scenarios: Annotated[
263
+ bool, cyclopts.Parameter(negative="", group=storage_group)
264
+ ] = False,
265
+ # HTTP API Server
266
+ api: Annotated[bool, cyclopts.Parameter(negative="", group=api_group)] = False,
267
+ api_host: Annotated[str, cyclopts.Parameter(group=api_group)] = "127.0.0.1",
268
+ api_port: Annotated[int, cyclopts.Parameter(group=api_group)] = 8080,
269
+ api_activity: Annotated[bool, cyclopts.Parameter(group=api_group)] = True,
270
+ # Device Creation
271
+ product: Annotated[
272
+ list[int] | None, cyclopts.Parameter(negative_iterable="", group=device_group)
273
+ ] = None,
274
+ color: Annotated[int, cyclopts.Parameter(group=device_group)] = 1,
275
+ color_temperature: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
276
+ infrared: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
277
+ hev: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
278
+ multizone: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
279
+ tile: Annotated[int, cyclopts.Parameter(group=device_group)] = 0,
280
+ # Multizone Options
281
+ multizone_zones: Annotated[
282
+ int | None, cyclopts.Parameter(group=multizone_group)
283
+ ] = None,
284
+ multizone_extended: Annotated[
285
+ bool, cyclopts.Parameter(group=multizone_group)
286
+ ] = True,
287
+ # Tile/Matrix Options
288
+ tile_count: Annotated[int | None, cyclopts.Parameter(group=tile_group)] = None,
289
+ tile_width: Annotated[int | None, cyclopts.Parameter(group=tile_group)] = None,
290
+ tile_height: Annotated[int | None, cyclopts.Parameter(group=tile_group)] = None,
291
+ # Serial Number Options
292
+ serial_prefix: Annotated[str, cyclopts.Parameter(group=serial_group)] = "d073d5",
293
+ serial_start: Annotated[int, cyclopts.Parameter(group=serial_group)] = 1,
294
+ ) -> bool | None:
295
+ """Start the LIFX emulator with configurable devices.
296
+
297
+ Creates virtual LIFX devices that respond to the LIFX LAN protocol. Supports
298
+ creating devices by product ID or by device type (color, multizone, tile, etc).
299
+ State can optionally be persisted across restarts.
300
+
301
+ Args:
302
+ bind: IP address to bind to.
303
+ port: UDP port to listen on.
304
+ verbose: Enable verbose logging showing all packets sent and received.
305
+ persistent: Enable persistent storage of device state across restarts.
306
+ persistent_scenarios: Enable persistent storage of test scenarios.
307
+ Requires --persistent to be enabled.
308
+ api: Enable HTTP API server for monitoring and runtime device management.
309
+ api_host: API server host to bind to.
310
+ api_port: API server port.
311
+ api_activity: Enable activity logging in API. Disable to reduce traffic
312
+ and save UI space on the monitoring dashboard.
313
+ product: Create devices by product ID. Can be specified multiple times.
314
+ Run 'lifx-emulator list-products' to see available products.
315
+ color: Number of full-color RGB lights to emulate. Defaults to 1.
316
+ color_temperature: Number of color temperature (white spectrum) lights.
317
+ infrared: Number of infrared lights with night vision capability.
318
+ hev: Number of HEV/Clean lights with UV-C germicidal capability.
319
+ multizone: Number of multizone strip or beam devices.
320
+ multizone_zones: Number of zones per multizone device. Uses product
321
+ defaults if not specified.
322
+ multizone_extended: Enable extended multizone support (Beam).
323
+ Set --no-multizone-extended for basic multizone (Z) devices.
324
+ tile: Number of tile/matrix chain devices.
325
+ tile_count: Number of tiles per device. Uses product defaults if not
326
+ specified (5 for Tile, 1 for Candle/Ceiling).
327
+ tile_width: Width of each tile in pixels. Uses product defaults if not
328
+ specified (8 for most devices).
329
+ tile_height: Height of each tile in pixels. Uses product defaults if
330
+ not specified (8 for most devices).
331
+ serial_prefix: Serial number prefix as 6 hex characters.
332
+ serial_start: Starting serial suffix for auto-incrementing device serials.
333
+
334
+ Examples:
335
+ Start with default configuration (1 color light):
336
+ lifx-emulator
337
+
338
+ Enable HTTP API server for monitoring:
339
+ lifx-emulator --api
340
+
341
+ Create specific products by ID (see list-products command):
342
+ lifx-emulator --product 27 --product 32 --product 55
343
+
344
+ Start on custom port with verbose logging:
345
+ lifx-emulator --port 56700 --verbose
346
+
347
+ Create diverse devices with API:
348
+ lifx-emulator --color 2 --multizone 1 --tile 1 --api --verbose
349
+
350
+ Create only specific device types:
351
+ lifx-emulator --color 0 --infrared 3 --hev 2
352
+
353
+ Custom serial prefix:
354
+ lifx-emulator --serial-prefix cafe00 --color 5
355
+
356
+ Mix products and device types:
357
+ lifx-emulator --product 27 --color 2 --multizone 1
358
+
359
+ Enable persistent storage:
360
+ lifx-emulator --persistent --api
361
+ """
362
+ logger: logging.Logger = _setup_logging(verbose)
363
+
364
+ # Validate that --persistent-scenarios requires --persistent
365
+ if persistent_scenarios and not persistent:
366
+ logger.error("--persistent-scenarios requires --persistent")
367
+ return False
368
+
369
+ # Initialize storage if persistence is enabled
370
+ storage = AsyncDeviceStorage() if persistent else None
371
+ if persistent and storage:
372
+ logger.info("Persistent storage enabled at %s", storage.storage_dir)
373
+
374
+ # Build device list based on parameters
375
+ devices = []
376
+ serial_num = serial_start
377
+
378
+ # Helper to generate serials
379
+ def get_serial():
380
+ nonlocal serial_num
381
+ serial = f"{serial_prefix}{serial_num:06x}"
382
+ serial_num += 1
383
+ return serial
384
+
385
+ # Check if we should restore devices from persistent storage
386
+ # When persistent is enabled, we only create new devices if explicitly requested
387
+ restore_from_storage = False
388
+ if persistent and storage:
389
+ saved_serials = storage.list_devices()
390
+ # Check if user explicitly requested device creation
391
+ user_requested_devices = (
392
+ product is not None
393
+ or color != 1 # color has default value of 1
394
+ or color_temperature != 0
395
+ or infrared != 0
396
+ or hev != 0
397
+ or multizone != 0
398
+ or tile != 0
399
+ )
400
+
401
+ if saved_serials and not user_requested_devices:
402
+ # Restore saved devices
403
+ restore_from_storage = True
404
+ logger.info(
405
+ f"Restoring {len(saved_serials)} device(s) from persistent storage"
406
+ )
407
+ for saved_serial in saved_serials:
408
+ saved_state = storage.load_device_state(saved_serial)
409
+ if saved_state:
410
+ try:
411
+ # Create device with the saved serial and product ID
412
+ device = create_device(
413
+ saved_state["product"], serial=saved_serial, storage=storage
414
+ )
415
+ devices.append(device)
416
+ except Exception as e:
417
+ logger.error("Failed to restore device %s: %s", saved_serial, e)
418
+ elif not saved_serials and not user_requested_devices:
419
+ # Persistent storage is empty and no devices requested
420
+ logger.info(
421
+ "Persistent storage enabled but empty. Starting with no devices."
422
+ )
423
+ logger.info(
424
+ "Use API or restart with device flags "
425
+ "(--color, --product, etc.) to add devices."
426
+ )
427
+
428
+ # Create new devices if not restoring from storage
429
+ if not restore_from_storage:
430
+ # Create devices from product IDs if specified
431
+ if product:
432
+ for pid in product:
433
+ try:
434
+ devices.append(
435
+ create_device(pid, serial=get_serial(), storage=storage)
436
+ )
437
+ except ValueError as e:
438
+ logger.error("Failed to create device: %s", e)
439
+ logger.info(
440
+ "Run 'lifx-emulator list-products' to see available products"
441
+ )
442
+ return
443
+ # If using --product, don't create default devices
444
+ # Set color to 0 by default
445
+ if (
446
+ color == 1
447
+ and color_temperature == 0
448
+ and infrared == 0
449
+ and hev == 0
450
+ and multizone == 0
451
+ ):
452
+ color = 0
453
+
454
+ # When persistent is enabled, don't create default devices
455
+ # User must explicitly request devices
456
+ if (
457
+ persistent
458
+ and color == 1
459
+ and color_temperature == 0
460
+ and infrared == 0
461
+ and hev == 0
462
+ and multizone == 0
463
+ and tile == 0
464
+ ):
465
+ color = 0
466
+
467
+ # Create color lights
468
+ for _ in range(color):
469
+ devices.append(create_color_light(get_serial(), storage=storage))
470
+
471
+ # Create color temperature lights
472
+ for _ in range(color_temperature):
473
+ devices.append(
474
+ create_color_temperature_light(get_serial(), storage=storage)
475
+ )
476
+
477
+ # Create infrared lights
478
+ for _ in range(infrared):
479
+ devices.append(create_infrared_light(get_serial(), storage=storage))
480
+
481
+ # Create HEV lights
482
+ for _ in range(hev):
483
+ devices.append(create_hev_light(get_serial(), storage=storage))
484
+
485
+ # Create multizone devices (strips/beams)
486
+ for _ in range(multizone):
487
+ devices.append(
488
+ create_multizone_light(
489
+ get_serial(),
490
+ zone_count=multizone_zones,
491
+ extended_multizone=multizone_extended,
492
+ storage=storage,
493
+ )
494
+ )
495
+
496
+ # Create tile devices
497
+ for _ in range(tile):
498
+ devices.append(
499
+ create_tile_device(
500
+ get_serial(),
501
+ tile_count=tile_count,
502
+ tile_width=tile_width,
503
+ tile_height=tile_height,
504
+ storage=storage,
505
+ )
506
+ )
507
+
508
+ if not devices:
509
+ if persistent:
510
+ logger.warning("No devices configured. Server will run with no devices.")
511
+ logger.info("Use API (--api) or restart with device flags to add devices.")
512
+ else:
513
+ logger.error(
514
+ "No devices configured. Use --color, --multizone, --tile, "
515
+ "etc. to add devices."
516
+ )
517
+ return
518
+
519
+ # Set port for all devices
520
+ for device in devices:
521
+ device.state.port = port
522
+
523
+ # Log device information
524
+ logger.info("Starting LIFX Emulator on %s:%s", bind, port)
525
+ logger.info("Created %s emulated device(s):", len(devices))
526
+ for device in devices:
527
+ label = device.state.label
528
+ serial = device.state.serial
529
+ caps = _format_capabilities(device)
530
+ logger.info(" • %s (%s) - %s", label, serial, caps)
531
+
532
+ # Start LIFX server
533
+ server = EmulatedLifxServer(
534
+ devices,
535
+ bind,
536
+ port,
537
+ track_activity=api_activity if api else False,
538
+ storage=storage,
539
+ persist_scenarios=persistent_scenarios,
540
+ )
541
+ await server.start()
542
+
543
+ # Start API server if enabled
544
+ api_task = None
545
+ if api:
546
+ from lifx_emulator.api import run_api_server
547
+
548
+ logger.info("Starting HTTP API server on http://%s:%s", api_host, api_port)
549
+ api_task = asyncio.create_task(run_api_server(server, api_host, api_port))
550
+
551
+ # Set up graceful shutdown on signals
552
+ shutdown_event = asyncio.Event()
553
+ loop = asyncio.get_running_loop()
554
+
555
+ def signal_handler(signum, frame):
556
+ """Handle shutdown signals gracefully (thread-safe for asyncio)."""
557
+ # Use call_soon_threadsafe to safely set event from signal handler
558
+ loop.call_soon_threadsafe(shutdown_event.set)
559
+
560
+ # Register signal handlers for graceful shutdown
561
+ # Use signal.signal() instead of loop.add_signal_handler() for Windows compatibility
562
+ signal.signal(signal.SIGTERM, signal_handler)
563
+ signal.signal(signal.SIGINT, signal_handler)
564
+
565
+ # On Windows, also handle SIGBREAK
566
+ sigbreak = getattr(signal, "SIGBREAK", None)
567
+ if sigbreak is not None:
568
+ signal.signal(sigbreak, signal_handler)
569
+
570
+ try:
571
+ if api:
572
+ logger.info(
573
+ f"LIFX server running on {bind}:{port}, API server on http://{api_host}:{api_port}"
574
+ )
575
+ logger.info(
576
+ f"Open http://{api_host}:{api_port} in your browser "
577
+ "to view the monitoring dashboard"
578
+ )
579
+ elif verbose:
580
+ logger.info(
581
+ "Server running with verbose packet logging... Press Ctrl+C to stop"
582
+ )
583
+ else:
584
+ logger.info(
585
+ "Server running... Press Ctrl+C to stop (use --verbose to see packets)"
586
+ )
587
+
588
+ await shutdown_event.wait() # Wait for shutdown signal
589
+ finally:
590
+ logger.info("Shutting down server...")
591
+
592
+ # Shutdown storage first to flush pending writes
593
+ if storage:
594
+ await storage.shutdown()
595
+
596
+ await server.stop()
597
+ if api_task:
598
+ api_task.cancel()
599
+ try:
600
+ await api_task
601
+ except asyncio.CancelledError:
602
+ pass
603
+
604
+
605
+ def main():
606
+ """Entry point for the CLI."""
607
+ app()