lifx-emulator 3.0.1__py3-none-any.whl → 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lifx_emulator-3.0.1.dist-info → lifx_emulator-4.0.0.dist-info}/METADATA +3 -2
- {lifx_emulator-3.0.1.dist-info → lifx_emulator-4.0.0.dist-info}/RECORD +9 -7
- {lifx_emulator-3.0.1.dist-info → lifx_emulator-4.0.0.dist-info}/WHEEL +1 -1
- lifx_emulator_app/__main__.py +660 -132
- lifx_emulator_app/api/app.py +6 -1
- lifx_emulator_app/api/static/dashboard.js +588 -0
- lifx_emulator_app/api/templates/dashboard.html +1 -543
- lifx_emulator_app/config.py +314 -0
- {lifx_emulator-3.0.1.dist-info → lifx_emulator-4.0.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Configuration file support for lifx-emulator CLI."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
AUTO_DETECT_FILENAMES = ("lifx-emulator.yaml", "lifx-emulator.yml")
|
|
15
|
+
ENV_VAR = "LIFX_EMULATOR_CONFIG"
|
|
16
|
+
|
|
17
|
+
_SERIAL_PATTERN = re.compile(r"^[0-9a-fA-F]{12}$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HsbkConfig(BaseModel):
|
|
21
|
+
"""HSBK color value supporting both dict and [h, s, b, k] list input."""
|
|
22
|
+
|
|
23
|
+
hue: int = 0
|
|
24
|
+
saturation: int = 0
|
|
25
|
+
brightness: int = 65535
|
|
26
|
+
kelvin: int = 3500
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="before")
|
|
29
|
+
@classmethod
|
|
30
|
+
def accept_list_form(cls, data):
|
|
31
|
+
"""Convert [h, s, b, k] list to dict form."""
|
|
32
|
+
if isinstance(data, list | tuple):
|
|
33
|
+
if len(data) != 4:
|
|
34
|
+
msg = (
|
|
35
|
+
"HSBK list must have exactly 4 elements"
|
|
36
|
+
" [hue, saturation, brightness, kelvin]"
|
|
37
|
+
)
|
|
38
|
+
raise ValueError(msg)
|
|
39
|
+
return {
|
|
40
|
+
"hue": data[0],
|
|
41
|
+
"saturation": data[1],
|
|
42
|
+
"brightness": data[2],
|
|
43
|
+
"kelvin": data[3],
|
|
44
|
+
}
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
@field_validator("hue", "saturation", "brightness")
|
|
48
|
+
@classmethod
|
|
49
|
+
def validate_uint16(cls, v: int) -> int:
|
|
50
|
+
if not 0 <= v <= 65535:
|
|
51
|
+
msg = "Value must be between 0 and 65535"
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
return v
|
|
54
|
+
|
|
55
|
+
@field_validator("kelvin")
|
|
56
|
+
@classmethod
|
|
57
|
+
def validate_kelvin(cls, v: int) -> int:
|
|
58
|
+
if not 1500 <= v <= 9000:
|
|
59
|
+
msg = "Kelvin must be between 1500 and 9000"
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
return v
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ScenarioDefinition(BaseModel):
|
|
65
|
+
"""Scenario configuration for a single scope level."""
|
|
66
|
+
|
|
67
|
+
drop_packets: dict[int, float] | None = None
|
|
68
|
+
response_delays: dict[int, float] | None = None
|
|
69
|
+
malformed_packets: list[int] | None = None
|
|
70
|
+
invalid_field_values: list[int] | None = None
|
|
71
|
+
firmware_version: tuple[int, int] | None = None
|
|
72
|
+
partial_responses: list[int] | None = None
|
|
73
|
+
send_unhandled: bool | None = None
|
|
74
|
+
|
|
75
|
+
@field_validator("drop_packets", mode="before")
|
|
76
|
+
@classmethod
|
|
77
|
+
def convert_drop_packets_keys(cls, v):
|
|
78
|
+
"""Convert string keys to integers (YAML keys are often strings)."""
|
|
79
|
+
if isinstance(v, dict):
|
|
80
|
+
return {int(k): float(val) for k, val in v.items()}
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
@field_validator("response_delays", mode="before")
|
|
84
|
+
@classmethod
|
|
85
|
+
def convert_response_delays_keys(cls, v):
|
|
86
|
+
"""Convert string keys to integers (YAML keys are often strings)."""
|
|
87
|
+
if isinstance(v, dict):
|
|
88
|
+
return {int(k): float(val) for k, val in v.items()}
|
|
89
|
+
return v
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ScenariosConfig(BaseModel):
|
|
93
|
+
"""Scenarios configuration across all scope levels."""
|
|
94
|
+
|
|
95
|
+
global_scenario: ScenarioDefinition | None = Field(None, alias="global")
|
|
96
|
+
devices: dict[str, ScenarioDefinition] | None = None
|
|
97
|
+
types: dict[str, ScenarioDefinition] | None = None
|
|
98
|
+
locations: dict[str, ScenarioDefinition] | None = None
|
|
99
|
+
groups: dict[str, ScenarioDefinition] | None = None
|
|
100
|
+
|
|
101
|
+
model_config = {"populate_by_name": True}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DeviceDefinition(BaseModel):
|
|
105
|
+
"""A single device definition in the config file."""
|
|
106
|
+
|
|
107
|
+
product_id: int
|
|
108
|
+
serial: str | None = None
|
|
109
|
+
label: str | None = None
|
|
110
|
+
power_level: int | None = None
|
|
111
|
+
color: HsbkConfig | None = None
|
|
112
|
+
location: str | None = None
|
|
113
|
+
group: str | None = None
|
|
114
|
+
zone_count: int | None = None
|
|
115
|
+
zone_colors: list[HsbkConfig] | None = None
|
|
116
|
+
infrared_brightness: int | None = None
|
|
117
|
+
hev_cycle_duration: int | None = None
|
|
118
|
+
hev_indication: bool | None = None
|
|
119
|
+
tile_count: int | None = None
|
|
120
|
+
tile_width: int | None = None
|
|
121
|
+
tile_height: int | None = None
|
|
122
|
+
|
|
123
|
+
@field_validator("serial")
|
|
124
|
+
@classmethod
|
|
125
|
+
def validate_serial(cls, v: str | None) -> str | None:
|
|
126
|
+
if v is not None and not _SERIAL_PATTERN.match(v):
|
|
127
|
+
msg = "serial must be exactly 12 hex characters"
|
|
128
|
+
raise ValueError(msg)
|
|
129
|
+
return v
|
|
130
|
+
|
|
131
|
+
@field_validator("power_level")
|
|
132
|
+
@classmethod
|
|
133
|
+
def validate_power_level(cls, v: int | None) -> int | None:
|
|
134
|
+
if v is not None and v not in (0, 65535):
|
|
135
|
+
msg = "power_level must be 0 (off) or 65535 (on)"
|
|
136
|
+
raise ValueError(msg)
|
|
137
|
+
return v
|
|
138
|
+
|
|
139
|
+
@field_validator("infrared_brightness")
|
|
140
|
+
@classmethod
|
|
141
|
+
def validate_infrared_brightness(cls, v: int | None) -> int | None:
|
|
142
|
+
if v is not None and not 0 <= v <= 65535:
|
|
143
|
+
msg = "infrared_brightness must be between 0 and 65535"
|
|
144
|
+
raise ValueError(msg)
|
|
145
|
+
return v
|
|
146
|
+
|
|
147
|
+
@field_validator("hev_cycle_duration")
|
|
148
|
+
@classmethod
|
|
149
|
+
def validate_hev_cycle_duration(cls, v: int | None) -> int | None:
|
|
150
|
+
if v is not None and v < 0:
|
|
151
|
+
msg = "hev_cycle_duration must be non-negative"
|
|
152
|
+
raise ValueError(msg)
|
|
153
|
+
return v
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class EmulatorConfig(BaseModel):
|
|
157
|
+
"""Configuration file schema for lifx-emulator."""
|
|
158
|
+
|
|
159
|
+
# Server options
|
|
160
|
+
bind: str | None = None
|
|
161
|
+
port: int | None = None
|
|
162
|
+
verbose: bool | None = None
|
|
163
|
+
|
|
164
|
+
# Storage & Persistence
|
|
165
|
+
persistent: bool | None = None
|
|
166
|
+
persistent_scenarios: bool | None = None
|
|
167
|
+
|
|
168
|
+
# HTTP API Server
|
|
169
|
+
api: bool | None = None
|
|
170
|
+
api_host: str | None = None
|
|
171
|
+
api_port: int | None = None
|
|
172
|
+
api_activity: bool | None = None
|
|
173
|
+
|
|
174
|
+
# Device creation (counts)
|
|
175
|
+
products: list[int] | None = None
|
|
176
|
+
color: int | None = None
|
|
177
|
+
color_temperature: int | None = None
|
|
178
|
+
infrared: int | None = None
|
|
179
|
+
hev: int | None = None
|
|
180
|
+
multizone: int | None = None
|
|
181
|
+
tile: int | None = None
|
|
182
|
+
switch: int | None = None
|
|
183
|
+
|
|
184
|
+
# Multizone options
|
|
185
|
+
multizone_zones: int | None = None
|
|
186
|
+
multizone_extended: bool | None = None
|
|
187
|
+
|
|
188
|
+
# Tile/Matrix options
|
|
189
|
+
tile_count: int | None = None
|
|
190
|
+
tile_width: int | None = None
|
|
191
|
+
tile_height: int | None = None
|
|
192
|
+
|
|
193
|
+
# Serial number options
|
|
194
|
+
serial_prefix: str | None = None
|
|
195
|
+
serial_start: int | None = None
|
|
196
|
+
|
|
197
|
+
# Per-device definitions
|
|
198
|
+
devices: list[DeviceDefinition] | None = None
|
|
199
|
+
|
|
200
|
+
# Scenario configuration
|
|
201
|
+
scenarios: ScenariosConfig | None = None
|
|
202
|
+
|
|
203
|
+
@field_validator("serial_prefix")
|
|
204
|
+
@classmethod
|
|
205
|
+
def validate_serial_prefix(cls, v: str | None) -> str | None:
|
|
206
|
+
if v is not None and (
|
|
207
|
+
len(v) != 6 or not all(c in "0123456789abcdefABCDEF" for c in v)
|
|
208
|
+
):
|
|
209
|
+
msg = "serial_prefix must be exactly 6 hex characters"
|
|
210
|
+
raise ValueError(msg)
|
|
211
|
+
return v
|
|
212
|
+
|
|
213
|
+
model_config = {"extra": "forbid"}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def resolve_config_path(config_flag: str | None) -> Path | None:
|
|
217
|
+
"""Resolve the config file path from flag, env var, or auto-detect.
|
|
218
|
+
|
|
219
|
+
Priority: --config flag > LIFX_EMULATOR_CONFIG env var > auto-detect in cwd.
|
|
220
|
+
Returns None if no config file is found.
|
|
221
|
+
"""
|
|
222
|
+
# 1. Explicit --config flag
|
|
223
|
+
if config_flag is not None:
|
|
224
|
+
path = Path(config_flag)
|
|
225
|
+
if not path.is_file():
|
|
226
|
+
msg = f"Config file not found: {path}"
|
|
227
|
+
raise FileNotFoundError(msg)
|
|
228
|
+
return path
|
|
229
|
+
|
|
230
|
+
# 2. Environment variable
|
|
231
|
+
env_path = os.environ.get(ENV_VAR)
|
|
232
|
+
if env_path:
|
|
233
|
+
path = Path(env_path)
|
|
234
|
+
if not path.is_file():
|
|
235
|
+
msg = f"Config file from {ENV_VAR} not found: {path}"
|
|
236
|
+
raise FileNotFoundError(msg)
|
|
237
|
+
return path
|
|
238
|
+
|
|
239
|
+
# 3. Auto-detect in current working directory
|
|
240
|
+
for filename in AUTO_DETECT_FILENAMES:
|
|
241
|
+
path = Path.cwd() / filename
|
|
242
|
+
if path.is_file():
|
|
243
|
+
return path
|
|
244
|
+
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def load_config(path: Path) -> EmulatorConfig:
|
|
249
|
+
"""Load and validate a config file from the given path."""
|
|
250
|
+
with open(path) as f:
|
|
251
|
+
raw = yaml.safe_load(f)
|
|
252
|
+
|
|
253
|
+
if raw is None:
|
|
254
|
+
return EmulatorConfig()
|
|
255
|
+
|
|
256
|
+
if not isinstance(raw, dict):
|
|
257
|
+
msg = f"Config file must contain a YAML mapping, got {type(raw).__name__}"
|
|
258
|
+
raise ValueError(msg)
|
|
259
|
+
|
|
260
|
+
return EmulatorConfig.model_validate(raw)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def merge_config(
|
|
264
|
+
config: EmulatorConfig,
|
|
265
|
+
cli_overrides: dict[str, Any],
|
|
266
|
+
) -> dict[str, Any]:
|
|
267
|
+
"""Merge config file values with CLI overrides.
|
|
268
|
+
|
|
269
|
+
CLI overrides (non-None values) take priority over config file values.
|
|
270
|
+
Returns a flat dict of final parameter values with defaults applied.
|
|
271
|
+
"""
|
|
272
|
+
defaults: dict[str, Any] = {
|
|
273
|
+
"bind": "127.0.0.1",
|
|
274
|
+
"port": 56700,
|
|
275
|
+
"verbose": False,
|
|
276
|
+
"persistent": False,
|
|
277
|
+
"persistent_scenarios": False,
|
|
278
|
+
"api": False,
|
|
279
|
+
"api_host": "127.0.0.1",
|
|
280
|
+
"api_port": 8080,
|
|
281
|
+
"api_activity": True,
|
|
282
|
+
"products": None,
|
|
283
|
+
"color": 0,
|
|
284
|
+
"color_temperature": 0,
|
|
285
|
+
"infrared": 0,
|
|
286
|
+
"hev": 0,
|
|
287
|
+
"multizone": 0,
|
|
288
|
+
"tile": 0,
|
|
289
|
+
"switch": 0,
|
|
290
|
+
"multizone_zones": None,
|
|
291
|
+
"multizone_extended": True,
|
|
292
|
+
"tile_count": None,
|
|
293
|
+
"tile_width": None,
|
|
294
|
+
"tile_height": None,
|
|
295
|
+
"serial_prefix": "d073d5",
|
|
296
|
+
"serial_start": 1,
|
|
297
|
+
"devices": None,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# Start with defaults
|
|
301
|
+
result = dict(defaults)
|
|
302
|
+
|
|
303
|
+
# Layer config file values (override defaults where set)
|
|
304
|
+
config_dict = config.model_dump(exclude_none=True)
|
|
305
|
+
for key, value in config_dict.items():
|
|
306
|
+
if key in result:
|
|
307
|
+
result[key] = value
|
|
308
|
+
|
|
309
|
+
# Layer CLI overrides (override config where explicitly set)
|
|
310
|
+
for key, value in cli_overrides.items():
|
|
311
|
+
if value is not None and key in result:
|
|
312
|
+
result[key] = value
|
|
313
|
+
|
|
314
|
+
return result
|
|
File without changes
|