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.
- lifx_emulator/__init__.py +31 -0
- lifx_emulator/__main__.py +607 -0
- lifx_emulator/api.py +1825 -0
- lifx_emulator/async_storage.py +308 -0
- lifx_emulator/constants.py +33 -0
- lifx_emulator/device.py +750 -0
- lifx_emulator/device_states.py +114 -0
- lifx_emulator/factories.py +380 -0
- lifx_emulator/handlers/__init__.py +39 -0
- lifx_emulator/handlers/base.py +49 -0
- lifx_emulator/handlers/device_handlers.py +340 -0
- lifx_emulator/handlers/light_handlers.py +372 -0
- lifx_emulator/handlers/multizone_handlers.py +249 -0
- lifx_emulator/handlers/registry.py +110 -0
- lifx_emulator/handlers/tile_handlers.py +309 -0
- lifx_emulator/observers.py +139 -0
- lifx_emulator/products/__init__.py +28 -0
- lifx_emulator/products/generator.py +771 -0
- lifx_emulator/products/registry.py +1446 -0
- lifx_emulator/products/specs.py +242 -0
- lifx_emulator/products/specs.yml +327 -0
- lifx_emulator/protocol/__init__.py +1 -0
- lifx_emulator/protocol/base.py +334 -0
- lifx_emulator/protocol/const.py +8 -0
- lifx_emulator/protocol/generator.py +1371 -0
- lifx_emulator/protocol/header.py +159 -0
- lifx_emulator/protocol/packets.py +1351 -0
- lifx_emulator/protocol/protocol_types.py +844 -0
- lifx_emulator/protocol/serializer.py +379 -0
- lifx_emulator/scenario_manager.py +402 -0
- lifx_emulator/scenario_persistence.py +206 -0
- lifx_emulator/server.py +482 -0
- lifx_emulator/state_restorer.py +259 -0
- lifx_emulator/state_serializer.py +130 -0
- lifx_emulator/storage_protocol.py +100 -0
- lifx_emulator-1.0.0.dist-info/METADATA +445 -0
- lifx_emulator-1.0.0.dist-info/RECORD +40 -0
- lifx_emulator-1.0.0.dist-info/WHEEL +4 -0
- lifx_emulator-1.0.0.dist-info/entry_points.txt +2 -0
- 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()
|