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,114 @@
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
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
+
98
+
99
+ @dataclass
100
+ class WaveformState:
101
+ """Waveform effect state."""
102
+
103
+ waveform_active: bool = False
104
+ waveform_type: int = 0
105
+ waveform_transient: bool = False
106
+ waveform_color: LightHsbk = field(
107
+ default_factory=lambda: LightHsbk(
108
+ hue=0, saturation=0, brightness=0, kelvin=3500
109
+ )
110
+ )
111
+ waveform_period_ms: int = 0
112
+ waveform_cycles: float = 0
113
+ waveform_duty_cycle: int = 0
114
+ waveform_skew_ratio: int = 0
@@ -0,0 +1,380 @@
1
+ """Factory functions for creating emulated LIFX devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import time
7
+ from typing import TYPE_CHECKING
8
+
9
+ from lifx_emulator.device import DeviceState, EmulatedLifxDevice
10
+ from lifx_emulator.device_states import (
11
+ CoreDeviceState,
12
+ GroupState,
13
+ HevState,
14
+ InfraredState,
15
+ LocationState,
16
+ MatrixState,
17
+ MultiZoneState,
18
+ NetworkState,
19
+ WaveformState,
20
+ )
21
+ from lifx_emulator.products.registry import ProductInfo, get_product
22
+ from lifx_emulator.products.specs import (
23
+ get_default_tile_count,
24
+ get_default_zone_count,
25
+ get_tile_dimensions,
26
+ )
27
+ from lifx_emulator.protocol.protocol_types import LightHsbk
28
+ from lifx_emulator.state_restorer import StateRestorer
29
+
30
+ if TYPE_CHECKING:
31
+ from lifx_emulator.async_storage import AsyncDeviceStorage
32
+ from lifx_emulator.scenario_manager import HierarchicalScenarioManager
33
+
34
+
35
+ def create_color_light(
36
+ serial: str | None = None,
37
+ firmware_version: tuple[int, int] | None = None,
38
+ storage: AsyncDeviceStorage | None = None,
39
+ scenario_manager: HierarchicalScenarioManager | None = None,
40
+ ) -> EmulatedLifxDevice:
41
+ """Create a regular color light (LIFX Color)"""
42
+ return create_device(
43
+ 91,
44
+ serial=serial,
45
+ firmware_version=firmware_version,
46
+ storage=storage,
47
+ scenario_manager=scenario_manager,
48
+ ) # LIFX Color
49
+
50
+
51
+ def create_infrared_light(
52
+ serial: str | None = None,
53
+ firmware_version: tuple[int, int] | None = None,
54
+ storage: AsyncDeviceStorage | None = None,
55
+ scenario_manager: HierarchicalScenarioManager | None = None,
56
+ ) -> EmulatedLifxDevice:
57
+ """Create an infrared-enabled light (LIFX A19 Night Vision)"""
58
+ return create_device(
59
+ 29,
60
+ serial=serial,
61
+ firmware_version=firmware_version,
62
+ storage=storage,
63
+ scenario_manager=scenario_manager,
64
+ ) # LIFX A19 Night Vision
65
+
66
+
67
+ def create_hev_light(
68
+ serial: str | None = None,
69
+ firmware_version: tuple[int, int] | None = None,
70
+ storage: AsyncDeviceStorage | None = None,
71
+ scenario_manager: HierarchicalScenarioManager | None = None,
72
+ ) -> EmulatedLifxDevice:
73
+ """Create an HEV-enabled light (LIFX Clean)"""
74
+ return create_device(
75
+ 90,
76
+ serial=serial,
77
+ firmware_version=firmware_version,
78
+ storage=storage,
79
+ scenario_manager=scenario_manager,
80
+ ) # LIFX Clean
81
+
82
+
83
+ def create_multizone_light(
84
+ serial: str | None = None,
85
+ zone_count: int | None = None,
86
+ extended_multizone: bool = True,
87
+ firmware_version: tuple[int, int] | None = None,
88
+ storage: AsyncDeviceStorage | None = None,
89
+ scenario_manager: HierarchicalScenarioManager | None = None,
90
+ ) -> EmulatedLifxDevice:
91
+ """Create a multizone light (LIFX Beam)
92
+
93
+ Args:
94
+ serial: Optional serial
95
+ zone_count: Optional zone count (uses product default if not specified)
96
+ extended_multizone: enables support for extended multizone requests
97
+ firmware_version: Optional firmware version tuple (major, minor)
98
+ storage: Optional storage for persistence
99
+ scenario_manager: Optional scenario manager
100
+ """
101
+ return create_device(
102
+ 38,
103
+ serial=serial,
104
+ zone_count=zone_count,
105
+ extended_multizone=extended_multizone,
106
+ firmware_version=firmware_version,
107
+ storage=storage,
108
+ scenario_manager=scenario_manager,
109
+ )
110
+
111
+
112
+ def create_tile_device(
113
+ serial: str | None = None,
114
+ tile_count: int | None = None,
115
+ tile_width: int | None = None,
116
+ tile_height: int | None = None,
117
+ firmware_version: tuple[int, int] | None = None,
118
+ storage: AsyncDeviceStorage | None = None,
119
+ scenario_manager: HierarchicalScenarioManager | None = None,
120
+ ) -> EmulatedLifxDevice:
121
+ """Create a tile device (LIFX Tile)
122
+
123
+ Args:
124
+ serial: Optional serial
125
+ tile_count: Optional tile count (uses product default)
126
+ tile_width: Optional tile width in pixels (uses product default)
127
+ tile_height: Optional tile height in pixels (uses product default)
128
+ firmware_version: Optional firmware version tuple (major, minor)
129
+ storage: Optional storage for persistence
130
+ scenario_manager: Optional scenario manager
131
+ """
132
+ return create_device(
133
+ 55,
134
+ serial=serial,
135
+ tile_count=tile_count,
136
+ tile_width=tile_width,
137
+ tile_height=tile_height,
138
+ firmware_version=firmware_version,
139
+ storage=storage,
140
+ scenario_manager=scenario_manager,
141
+ ) # LIFX Tile
142
+
143
+
144
+ def create_color_temperature_light(
145
+ serial: str | None = None,
146
+ firmware_version: tuple[int, int] | None = None,
147
+ storage: AsyncDeviceStorage | None = None,
148
+ scenario_manager: HierarchicalScenarioManager | None = None,
149
+ ) -> EmulatedLifxDevice:
150
+ """Create a color temperature light (LIFX Mini White to Warm).
151
+
152
+ Variable color temperature, no RGB.
153
+ """
154
+ return create_device(
155
+ 50,
156
+ serial=serial,
157
+ firmware_version=firmware_version,
158
+ storage=storage,
159
+ scenario_manager=scenario_manager,
160
+ ) # LIFX Mini White to Warm
161
+
162
+
163
+ def create_device(
164
+ product_id: int,
165
+ serial: str | None = None,
166
+ zone_count: int | None = None,
167
+ extended_multizone: bool | None = None,
168
+ tile_count: int | None = None,
169
+ tile_width: int | None = None,
170
+ tile_height: int | None = None,
171
+ firmware_version: tuple[int, int] | None = None,
172
+ storage: AsyncDeviceStorage | None = None,
173
+ scenario_manager: HierarchicalScenarioManager | None = None,
174
+ ) -> EmulatedLifxDevice:
175
+ """Create a device for any LIFX product using the product registry.
176
+
177
+ Args:
178
+ product_id: Product ID from the LIFX product registry
179
+ serial: Optional serial (auto-generated if not provided)
180
+ zone_count: Number of zones for multizone devices (auto-determined)
181
+ extended_multizone: Enable extended multizone requests
182
+ tile_count: Number of tiles for matrix devices (default: 5)
183
+ tile_width: Width of each tile in pixels (default: 8)
184
+ tile_height: Height of each tile in pixels (default: 8)
185
+ firmware_version: Optional firmware version tuple (major, minor).
186
+ If not specified, uses 3.70 for extended_multizone
187
+ or 2.60 otherwise
188
+ storage: Optional storage for persistence
189
+
190
+ Returns:
191
+ EmulatedLifxDevice configured for the specified product
192
+
193
+ Raises:
194
+ ValueError: If product_id is not found in registry
195
+
196
+ Examples:
197
+ >>> # Create LIFX A19 (PID 27)
198
+ >>> device = create_device(27)
199
+ >>> # Create LIFX Z strip (PID 32) with 24 zones
200
+ >>> strip = create_device(32, zone_count=24)
201
+ >>> # Create LIFX Tile (PID 55) with 10 tiles
202
+ >>> tiles = create_device(55, tile_count=10)
203
+ """
204
+ # Get product info from registry
205
+ product_info: ProductInfo | None = get_product(product_id)
206
+ if product_info is None:
207
+ raise ValueError(f"Unknown product ID: {product_id}")
208
+
209
+ # Generate serial if not provided
210
+ if not serial:
211
+ # Use different prefixes for product types for easier identification
212
+ if product_info.has_matrix:
213
+ prefix = "d073d9" # Tiles
214
+ elif product_info.has_multizone:
215
+ prefix = "d073d8" # Strips/Beams
216
+ elif product_info.has_hev:
217
+ prefix = "d073d7" # HEV
218
+ elif product_info.has_infrared:
219
+ prefix = "d073d6" # Infrared
220
+ else:
221
+ prefix = "d073d5" # Regular lights
222
+ serial = f"{prefix}{random.randint(100000, 999999):06x}" # nosec
223
+
224
+ # Determine zone count for multizone devices
225
+ if product_info.has_multizone and zone_count is None:
226
+ # Try to get default from specs first
227
+ zone_count = get_default_zone_count(product_id) or 16
228
+
229
+ # Determine tile configuration for matrix devices
230
+ if product_info.has_matrix:
231
+ # Get tile dimensions from specs (always use specs for dimensions)
232
+ tile_dims = get_tile_dimensions(product_id)
233
+ if tile_dims:
234
+ tile_width, tile_height = tile_dims
235
+ else:
236
+ # Fallback to standard 8x8 tiles
237
+ if tile_width is None:
238
+ tile_width = 8
239
+ if tile_height is None:
240
+ tile_height = 8
241
+
242
+ # Get default tile count from specs
243
+ if tile_count is None:
244
+ specs_tile_count = get_default_tile_count(product_id)
245
+ if specs_tile_count is not None:
246
+ tile_count = specs_tile_count
247
+ else:
248
+ tile_count = 5 # Generic default
249
+
250
+ # Create default color based on product type
251
+ if (
252
+ not product_info.has_color
253
+ and product_info.temperature_range is not None
254
+ and product_info.temperature_range.min == product_info.temperature_range.max
255
+ ):
256
+ # Brightness only light
257
+ default_color = LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=2700)
258
+ elif (
259
+ not product_info.has_color
260
+ and product_info.temperature_range is not None
261
+ and product_info.temperature_range.min != product_info.temperature_range.max
262
+ ):
263
+ # Color temperature adjustable light
264
+ default_color = LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=3500)
265
+ else:
266
+ # Color devices - use a unique hue per device type
267
+ hue_map = {
268
+ "matrix": 43690, # Cyan
269
+ "multizone": 0, # Red
270
+ "hev": 32768, # Green
271
+ "infrared": 0, # Red
272
+ "color": 21845, # Orange
273
+ }
274
+ if product_info.has_matrix:
275
+ hue = hue_map["matrix"]
276
+ elif product_info.has_multizone:
277
+ hue = hue_map["multizone"]
278
+ elif product_info.has_hev:
279
+ hue = hue_map["hev"]
280
+ elif product_info.has_infrared:
281
+ hue = hue_map["infrared"]
282
+ else:
283
+ hue = hue_map["color"]
284
+ default_color = LightHsbk(
285
+ hue=hue, saturation=65535, brightness=32768, kelvin=3500
286
+ )
287
+
288
+ # Get a simplified label from product name
289
+ label = f"{product_info.name} {serial[-6:]}"
290
+
291
+ # Determine firmware version: use extended_multizone to set default,
292
+ # then override with explicit firmware_version if provided
293
+ # None defaults to True (3.70), only explicit False gives 2.60
294
+ if extended_multizone is False:
295
+ version_major = 2
296
+ version_minor = 60
297
+ else:
298
+ version_major = 3
299
+ version_minor = 70
300
+
301
+ # Override with explicit firmware_version if provided
302
+ if firmware_version is not None:
303
+ version_major, version_minor = firmware_version
304
+
305
+ core = CoreDeviceState(
306
+ serial=serial,
307
+ label=label,
308
+ power_level=65535, # Default to on
309
+ color=default_color,
310
+ vendor=product_info.vendor,
311
+ product=product_info.pid,
312
+ version_major=version_major,
313
+ version_minor=version_minor,
314
+ build_timestamp=int(time.time()),
315
+ mac_address=bytes.fromhex(serial[:12]),
316
+ )
317
+
318
+ # Create network, location, group, and waveform state
319
+ network = NetworkState()
320
+ location = LocationState()
321
+ group = GroupState()
322
+ waveform = WaveformState()
323
+
324
+ # Create capability-specific state objects
325
+ infrared_state = (
326
+ InfraredState(infrared_brightness=16384) if product_info.has_infrared else None
327
+ )
328
+ hev_state = HevState() if product_info.has_hev else None
329
+
330
+ multizone_state = None
331
+ if product_info.has_multizone and zone_count:
332
+ multizone_state = MultiZoneState(
333
+ zone_count=zone_count,
334
+ zone_colors=[], # Will be initialized by EmulatedLifxDevice
335
+ )
336
+
337
+ matrix_state = None
338
+ if product_info.has_matrix and tile_count:
339
+ matrix_state = MatrixState(
340
+ tile_count=tile_count,
341
+ tile_devices=[], # Will be initialized by EmulatedLifxDevice
342
+ tile_width=tile_width or 8,
343
+ tile_height=tile_height or 8,
344
+ )
345
+
346
+ # Determine if device supports extended multizone
347
+ firmware_version_int = (version_major << 16) | version_minor
348
+ has_extended_multizone = product_info.supports_extended_multizone(
349
+ firmware_version_int
350
+ )
351
+
352
+ # Create composed device state
353
+ state = DeviceState(
354
+ core=core,
355
+ network=network,
356
+ location=location,
357
+ group=group,
358
+ waveform=waveform,
359
+ infrared=infrared_state,
360
+ hev=hev_state,
361
+ multizone=multizone_state,
362
+ matrix=matrix_state,
363
+ has_color=product_info.has_color,
364
+ has_infrared=product_info.has_infrared,
365
+ has_multizone=product_info.has_multizone,
366
+ has_extended_multizone=has_extended_multizone,
367
+ has_matrix=product_info.has_matrix,
368
+ has_hev=product_info.has_hev,
369
+ )
370
+
371
+ # Restore saved state if persistence is enabled
372
+ if storage:
373
+ restorer = StateRestorer(storage)
374
+ restorer.restore_if_available(state)
375
+
376
+ return EmulatedLifxDevice(
377
+ state,
378
+ storage=storage,
379
+ scenario_manager=scenario_manager,
380
+ )
@@ -0,0 +1,39 @@
1
+ """Packet handler infrastructure using Strategy pattern.
2
+
3
+ This module provides the base classes and registry for handling LIFX protocol packets.
4
+ Each packet type has a dedicated handler class that implements the business logic.
5
+ """
6
+
7
+ from lifx_emulator.handlers.base import PacketHandler
8
+ from lifx_emulator.handlers.device_handlers import ALL_DEVICE_HANDLERS
9
+ from lifx_emulator.handlers.light_handlers import ALL_LIGHT_HANDLERS
10
+ from lifx_emulator.handlers.multizone_handlers import ALL_MULTIZONE_HANDLERS
11
+ from lifx_emulator.handlers.registry import HandlerRegistry
12
+ from lifx_emulator.handlers.tile_handlers import ALL_TILE_HANDLERS
13
+
14
+ __all__ = [
15
+ "PacketHandler",
16
+ "HandlerRegistry",
17
+ "ALL_DEVICE_HANDLERS",
18
+ "ALL_LIGHT_HANDLERS",
19
+ "ALL_MULTIZONE_HANDLERS",
20
+ "ALL_TILE_HANDLERS",
21
+ "create_default_registry",
22
+ ]
23
+
24
+
25
+ def create_default_registry() -> HandlerRegistry:
26
+ """Create a handler registry with all default handlers registered.
27
+
28
+ Returns:
29
+ HandlerRegistry with all built-in handlers
30
+ """
31
+ registry = HandlerRegistry()
32
+
33
+ # Register all handler categories
34
+ registry.register_all(ALL_DEVICE_HANDLERS)
35
+ registry.register_all(ALL_LIGHT_HANDLERS)
36
+ registry.register_all(ALL_MULTIZONE_HANDLERS)
37
+ registry.register_all(ALL_TILE_HANDLERS)
38
+
39
+ return registry
@@ -0,0 +1,49 @@
1
+ """Base classes for packet handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from lifx_emulator.device import DeviceState
10
+
11
+
12
+ class PacketHandler(ABC):
13
+ """Base class for all packet handlers.
14
+
15
+ Each handler implements the logic for processing a specific packet type
16
+ and optionally generating a response packet.
17
+
18
+ Handlers are stateless and operate on the provided DeviceState.
19
+ """
20
+
21
+ # Subclasses must define the packet type they handle
22
+ PKT_TYPE: int
23
+
24
+ @abstractmethod
25
+ def handle(
26
+ self, device_state: DeviceState, packet: Any | None, res_required: bool
27
+ ) -> list[Any]:
28
+ """Handle the packet and return response packet(s).
29
+
30
+ Args:
31
+ device_state: Current device state to read/modify
32
+ packet: Unpacked packet object (None for packets with no payload)
33
+ res_required: Whether client requested a response (res_required
34
+ flag from header)
35
+
36
+ Returns:
37
+ List of response packets (empty list if no response needed).
38
+ This unified return type simplifies packet processing logic.
39
+
40
+ Notes:
41
+ - Handlers should modify device_state directly for SET operations
42
+ - Handlers should check device capabilities before processing
43
+ - Return empty list [] if the device doesn't support this packet type
44
+ - Always return a list, even for single responses: [packet]
45
+ """
46
+ pass
47
+
48
+ def __repr__(self) -> str:
49
+ return f"{self.__class__.__name__}(PKT_TYPE={self.PKT_TYPE})"