lifx-emulator 1.0.2__py3-none-any.whl → 2.1.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 (58) hide show
  1. lifx_emulator/__init__.py +1 -1
  2. lifx_emulator/__main__.py +26 -51
  3. lifx_emulator/api/__init__.py +18 -0
  4. lifx_emulator/api/app.py +154 -0
  5. lifx_emulator/api/mappers/__init__.py +5 -0
  6. lifx_emulator/api/mappers/device_mapper.py +114 -0
  7. lifx_emulator/api/models.py +133 -0
  8. lifx_emulator/api/routers/__init__.py +11 -0
  9. lifx_emulator/api/routers/devices.py +130 -0
  10. lifx_emulator/api/routers/monitoring.py +52 -0
  11. lifx_emulator/api/routers/scenarios.py +247 -0
  12. lifx_emulator/api/services/__init__.py +8 -0
  13. lifx_emulator/api/services/device_service.py +198 -0
  14. lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
  15. lifx_emulator/devices/__init__.py +37 -0
  16. lifx_emulator/devices/device.py +333 -0
  17. lifx_emulator/devices/manager.py +256 -0
  18. lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
  19. lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
  20. lifx_emulator/devices/states.py +346 -0
  21. lifx_emulator/factories/__init__.py +37 -0
  22. lifx_emulator/factories/builder.py +371 -0
  23. lifx_emulator/factories/default_config.py +158 -0
  24. lifx_emulator/factories/factory.py +221 -0
  25. lifx_emulator/factories/firmware_config.py +59 -0
  26. lifx_emulator/factories/serial_generator.py +82 -0
  27. lifx_emulator/handlers/base.py +1 -1
  28. lifx_emulator/handlers/device_handlers.py +10 -28
  29. lifx_emulator/handlers/light_handlers.py +5 -9
  30. lifx_emulator/handlers/multizone_handlers.py +1 -1
  31. lifx_emulator/handlers/tile_handlers.py +31 -11
  32. lifx_emulator/products/generator.py +389 -170
  33. lifx_emulator/products/registry.py +52 -40
  34. lifx_emulator/products/specs.py +12 -13
  35. lifx_emulator/protocol/base.py +175 -63
  36. lifx_emulator/protocol/generator.py +18 -5
  37. lifx_emulator/protocol/packets.py +7 -7
  38. lifx_emulator/protocol/protocol_types.py +35 -62
  39. lifx_emulator/repositories/__init__.py +22 -0
  40. lifx_emulator/repositories/device_repository.py +155 -0
  41. lifx_emulator/repositories/storage_backend.py +107 -0
  42. lifx_emulator/scenarios/__init__.py +22 -0
  43. lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
  44. lifx_emulator/scenarios/models.py +112 -0
  45. lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
  46. lifx_emulator/server.py +42 -66
  47. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/METADATA +1 -1
  48. lifx_emulator-2.1.0.dist-info/RECORD +62 -0
  49. lifx_emulator/device.py +0 -750
  50. lifx_emulator/device_states.py +0 -114
  51. lifx_emulator/factories.py +0 -380
  52. lifx_emulator/storage_protocol.py +0 -100
  53. lifx_emulator-1.0.2.dist-info/RECORD +0 -40
  54. /lifx_emulator/{observers.py → devices/observers.py} +0 -0
  55. /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
  56. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/WHEEL +0 -0
  57. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/entry_points.txt +0 -0
  58. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,346 @@
1
+ """Focused state dataclasses following Single Responsibility Principle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from lifx_emulator.constants import LIFX_UDP_PORT
11
+ from lifx_emulator.protocol.protocol_types import LightHsbk
12
+
13
+
14
+ @dataclass
15
+ class CoreDeviceState:
16
+ """Core device identification and basic state."""
17
+
18
+ serial: str
19
+ label: str
20
+ power_level: int
21
+ color: LightHsbk
22
+ vendor: int
23
+ product: int
24
+ version_major: int
25
+ version_minor: int
26
+ build_timestamp: int
27
+ uptime_ns: int = 0
28
+ mac_address: bytes = field(default_factory=lambda: bytes.fromhex("d073d5123456"))
29
+ port: int = LIFX_UDP_PORT
30
+
31
+
32
+ @dataclass
33
+ class NetworkState:
34
+ """Network and connectivity state."""
35
+
36
+ wifi_signal: float = -45.0
37
+
38
+
39
+ @dataclass
40
+ class LocationState:
41
+ """Device location metadata."""
42
+
43
+ location_id: bytes = field(default_factory=lambda: uuid.uuid4().bytes)
44
+ location_label: str = "Test Location"
45
+ location_updated_at: int = field(default_factory=lambda: int(time.time() * 1e9))
46
+
47
+
48
+ @dataclass
49
+ class GroupState:
50
+ """Device group metadata."""
51
+
52
+ group_id: bytes = field(default_factory=lambda: uuid.uuid4().bytes)
53
+ group_label: str = "Test Group"
54
+ group_updated_at: int = field(default_factory=lambda: int(time.time() * 1e9))
55
+
56
+
57
+ @dataclass
58
+ class InfraredState:
59
+ """Infrared capability state."""
60
+
61
+ infrared_brightness: int = 0 # 0-65535
62
+
63
+
64
+ @dataclass
65
+ class HevState:
66
+ """HEV (germicidal UV) capability state."""
67
+
68
+ hev_cycle_duration_s: int = 7200 # 2 hours default
69
+ hev_cycle_remaining_s: int = 0
70
+ hev_cycle_last_power: bool = False
71
+ hev_indication: bool = True
72
+ hev_last_result: int = 0 # 0=success
73
+
74
+
75
+ @dataclass
76
+ class MultiZoneState:
77
+ """Multizone (strip/beam) capability state."""
78
+
79
+ zone_count: int
80
+ zone_colors: list[LightHsbk]
81
+ effect_type: int = 0 # 0=OFF, 1=MOVE, 2=RESERVED
82
+ effect_speed: int = 5 # Duration of one cycle in seconds
83
+
84
+
85
+ @dataclass
86
+ class MatrixState:
87
+ """Matrix (tile/candle) capability state."""
88
+
89
+ tile_count: int
90
+ tile_devices: list[dict[str, Any]]
91
+ tile_width: int
92
+ tile_height: int
93
+ effect_type: int = 0 # 0=OFF, 2=MORPH, 3=FLAME, 5=SKY
94
+ effect_speed: int = 5 # Duration of one cycle in seconds
95
+ effect_palette_count: int = 0
96
+ effect_palette: list[LightHsbk] = field(default_factory=list)
97
+ effect_sky_type: int = 0 # 0=SUNRISE, 1=SUNSET, 2=CLOUDS (only when effect_type=5)
98
+ effect_cloud_sat_min: int = (
99
+ 0 # Min cloud saturation 0-200 (only when effect_type=5)
100
+ )
101
+ effect_cloud_sat_max: int = (
102
+ 0 # Max cloud saturation 0-200 (only when effect_type=5)
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class WaveformState:
108
+ """Waveform effect state."""
109
+
110
+ waveform_active: bool = False
111
+ waveform_type: int = 0
112
+ waveform_transient: bool = False
113
+ waveform_color: LightHsbk = field(
114
+ default_factory=lambda: LightHsbk(
115
+ hue=0, saturation=0, brightness=0, kelvin=3500
116
+ )
117
+ )
118
+ waveform_period_ms: int = 0
119
+ waveform_cycles: float = 0
120
+ waveform_duty_cycle: int = 0
121
+ waveform_skew_ratio: int = 0
122
+
123
+
124
+ @dataclass
125
+ class DeviceState:
126
+ """Composed device state following Single Responsibility Principle.
127
+
128
+ Each aspect of device state is managed by a focused sub-state object.
129
+ Properties are automatically delegated to the appropriate state object
130
+ using __getattr__ and __setattr__ magic methods.
131
+
132
+ Examples:
133
+ >>> state.label # Delegates to state.core.label
134
+ >>> state.location_label # Delegates to state.location.location_label
135
+ >>> state.zone_count # Delegates to state.multizone.zone_count (if present)
136
+ """
137
+
138
+ core: CoreDeviceState
139
+ network: NetworkState
140
+ location: LocationState
141
+ group: GroupState
142
+ waveform: WaveformState
143
+
144
+ # Optional capability-specific state
145
+ infrared: InfraredState | None = None
146
+ hev: HevState | None = None
147
+ multizone: MultiZoneState | None = None
148
+ matrix: MatrixState | None = None
149
+
150
+ # Capability flags (kept for convenience)
151
+ has_color: bool = True
152
+ has_infrared: bool = False
153
+ has_multizone: bool = False
154
+ has_extended_multizone: bool = False
155
+ has_matrix: bool = False
156
+ has_hev: bool = False
157
+
158
+ # Attribute routing map: maps attribute prefixes to state objects
159
+ # This eliminates ~360 lines of property boilerplate
160
+ _ATTRIBUTE_ROUTES = {
161
+ # Core properties (no prefix)
162
+ "serial": "core",
163
+ "label": "core",
164
+ "power_level": "core",
165
+ "color": "core",
166
+ "vendor": "core",
167
+ "product": "core",
168
+ "version_major": "core",
169
+ "version_minor": "core",
170
+ "build_timestamp": "core",
171
+ "uptime_ns": "core",
172
+ "mac_address": "core",
173
+ "port": "core",
174
+ # Network properties
175
+ "wifi_signal": "network",
176
+ # Location properties
177
+ "location_id": "location",
178
+ "location_label": "location",
179
+ "location_updated_at": "location",
180
+ # Group properties
181
+ "group_id": "group",
182
+ "group_label": "group",
183
+ "group_updated_at": "group",
184
+ # Waveform properties
185
+ "waveform_active": "waveform",
186
+ "waveform_type": "waveform",
187
+ "waveform_transient": "waveform",
188
+ "waveform_color": "waveform",
189
+ "waveform_period_ms": "waveform",
190
+ "waveform_cycles": "waveform",
191
+ "waveform_duty_cycle": "waveform",
192
+ "waveform_skew_ratio": "waveform",
193
+ # Infrared properties
194
+ "infrared_brightness": "infrared",
195
+ # HEV properties
196
+ "hev_cycle_duration_s": "hev",
197
+ "hev_cycle_remaining_s": "hev",
198
+ "hev_cycle_last_power": "hev",
199
+ "hev_indication": "hev",
200
+ "hev_last_result": "hev",
201
+ # Multizone properties
202
+ "zone_count": "multizone",
203
+ "zone_colors": "multizone",
204
+ "multizone_effect_type": ("multizone", "effect_type"),
205
+ "multizone_effect_speed": ("multizone", "effect_speed"),
206
+ # Matrix/Tile properties
207
+ "tile_count": "matrix",
208
+ "tile_devices": "matrix",
209
+ "tile_width": "matrix",
210
+ "tile_height": "matrix",
211
+ "tile_effect_type": ("matrix", "effect_type"),
212
+ "tile_effect_speed": ("matrix", "effect_speed"),
213
+ "tile_effect_palette_count": ("matrix", "effect_palette_count"),
214
+ "tile_effect_palette": ("matrix", "effect_palette"),
215
+ "tile_effect_sky_type": ("matrix", "effect_sky_type"),
216
+ "tile_effect_cloud_sat_min": ("matrix", "effect_cloud_sat_min"),
217
+ "tile_effect_cloud_sat_max": ("matrix", "effect_cloud_sat_max"),
218
+ }
219
+
220
+ # Default values for optional state attributes when state object is None
221
+ _OPTIONAL_DEFAULTS = {
222
+ "infrared_brightness": 0,
223
+ "hev_cycle_duration_s": 0,
224
+ "hev_cycle_remaining_s": 0,
225
+ "hev_cycle_last_power": False,
226
+ "hev_indication": False,
227
+ "hev_last_result": 0,
228
+ "zone_count": 0,
229
+ "zone_colors": [],
230
+ "multizone_effect_type": 0,
231
+ "multizone_effect_speed": 0,
232
+ "tile_count": 0,
233
+ "tile_devices": [],
234
+ "tile_width": 8,
235
+ "tile_height": 8,
236
+ "tile_effect_type": 0,
237
+ "tile_effect_speed": 0,
238
+ "tile_effect_palette_count": 0,
239
+ "tile_effect_palette": [],
240
+ "tile_effect_sky_type": 0,
241
+ "tile_effect_cloud_sat_min": 0,
242
+ "tile_effect_cloud_sat_max": 0,
243
+ }
244
+
245
+ def get_target_bytes(self) -> bytes:
246
+ """Get target bytes for this device."""
247
+ return bytes.fromhex(self.core.serial) + b"\x00\x00"
248
+
249
+ def __getattr__(self, name: str) -> Any:
250
+ """Dynamically delegate attribute access to appropriate state object.
251
+
252
+ This eliminates ~180 lines of @property boilerplate.
253
+
254
+ Args:
255
+ name: Attribute name being accessed
256
+
257
+ Returns:
258
+ Attribute value from the appropriate state object
259
+
260
+ Raises:
261
+ AttributeError: If attribute is not found
262
+ """
263
+ # Check if this attribute has a routing rule
264
+ if name in self._ATTRIBUTE_ROUTES:
265
+ route = self._ATTRIBUTE_ROUTES[name]
266
+
267
+ # Route can be either 'state_name' or ('state_name', 'attr_name')
268
+ if isinstance(route, tuple):
269
+ state_name, attr_name = route
270
+ else:
271
+ state_name = route
272
+ attr_name = name
273
+
274
+ # Get the state object
275
+ state_obj = object.__getattribute__(self, state_name)
276
+
277
+ # Handle optional state objects (infrared, hev, multizone, matrix)
278
+ if state_obj is None:
279
+ # Return default value for optional attributes
280
+ return self._OPTIONAL_DEFAULTS.get(name)
281
+
282
+ # Delegate to the state object
283
+ return getattr(state_obj, attr_name)
284
+
285
+ # If not in routing map, raise AttributeError
286
+ raise AttributeError(
287
+ f"'{type(self).__name__}' object has no attribute '{name}'"
288
+ )
289
+
290
+ def __setattr__(self, name: str, value: Any) -> None:
291
+ """Dynamically delegate attribute writes to appropriate state object.
292
+
293
+ This eliminates ~180 lines of @property.setter boilerplate.
294
+
295
+ Args:
296
+ name: Attribute name being set
297
+ value: Value to set
298
+
299
+ Note:
300
+ Dataclass fields and private attributes bypass delegation.
301
+ """
302
+ # Dataclass fields and private attributes use normal assignment
303
+ if name in {
304
+ "core",
305
+ "network",
306
+ "location",
307
+ "group",
308
+ "waveform",
309
+ "infrared",
310
+ "hev",
311
+ "multizone",
312
+ "matrix",
313
+ "has_color",
314
+ "has_infrared",
315
+ "has_multizone",
316
+ "has_extended_multizone",
317
+ "has_matrix",
318
+ "has_hev",
319
+ } or name.startswith("_"):
320
+ object.__setattr__(self, name, value)
321
+ return
322
+
323
+ # Check if this attribute has a routing rule
324
+ if name in self._ATTRIBUTE_ROUTES:
325
+ route = self._ATTRIBUTE_ROUTES[name]
326
+
327
+ # Route can be either 'state_name' or ('state_name', 'attr_name')
328
+ if isinstance(route, tuple):
329
+ state_name, attr_name = route
330
+ else:
331
+ state_name = route
332
+ attr_name = name
333
+
334
+ # Get the state object
335
+ state_obj = object.__getattribute__(self, state_name)
336
+
337
+ # Handle optional state objects - silently ignore writes if None
338
+ if state_obj is None:
339
+ return
340
+
341
+ # Delegate to the state object
342
+ setattr(state_obj, attr_name, value)
343
+ return
344
+
345
+ # For unknown attributes, use normal assignment (allows adding new attributes)
346
+ object.__setattr__(self, name, value)
@@ -0,0 +1,37 @@
1
+ """Device factory for creating emulated LIFX devices.
2
+
3
+ This package provides a clean, testable API for creating LIFX devices using:
4
+ - Builder pattern for flexible device construction
5
+ - Separate services for serial generation, color config, firmware config
6
+ - Product registry integration for accurate device specifications
7
+ """
8
+
9
+ from lifx_emulator.factories.builder import DeviceBuilder
10
+ from lifx_emulator.factories.default_config import DefaultColorConfig
11
+ from lifx_emulator.factories.factory import (
12
+ create_color_light,
13
+ create_color_temperature_light,
14
+ create_device,
15
+ create_hev_light,
16
+ create_infrared_light,
17
+ create_multizone_light,
18
+ create_tile_device,
19
+ )
20
+ from lifx_emulator.factories.firmware_config import FirmwareConfig
21
+ from lifx_emulator.factories.serial_generator import SerialGenerator
22
+
23
+ __all__ = [
24
+ # Builder and helpers
25
+ "DeviceBuilder",
26
+ "SerialGenerator",
27
+ "DefaultColorConfig",
28
+ "FirmwareConfig",
29
+ # Factory functions
30
+ "create_device",
31
+ "create_color_light",
32
+ "create_infrared_light",
33
+ "create_hev_light",
34
+ "create_multizone_light",
35
+ "create_tile_device",
36
+ "create_color_temperature_light",
37
+ ]