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.
@@ -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