lifx-emulator 2.4.0__py3-none-any.whl → 3.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 (70) hide show
  1. lifx_emulator-3.1.0.dist-info/METADATA +103 -0
  2. lifx_emulator-3.1.0.dist-info/RECORD +19 -0
  3. {lifx_emulator-2.4.0.dist-info → lifx_emulator-3.1.0.dist-info}/WHEEL +1 -1
  4. lifx_emulator-3.1.0.dist-info/entry_points.txt +2 -0
  5. lifx_emulator_app/__init__.py +10 -0
  6. {lifx_emulator → lifx_emulator_app}/__main__.py +2 -3
  7. {lifx_emulator → lifx_emulator_app}/api/__init__.py +1 -1
  8. {lifx_emulator → lifx_emulator_app}/api/app.py +9 -4
  9. {lifx_emulator → lifx_emulator_app}/api/mappers/__init__.py +1 -1
  10. {lifx_emulator → lifx_emulator_app}/api/mappers/device_mapper.py +1 -1
  11. {lifx_emulator → lifx_emulator_app}/api/models.py +1 -2
  12. lifx_emulator_app/api/routers/__init__.py +11 -0
  13. {lifx_emulator → lifx_emulator_app}/api/routers/devices.py +2 -2
  14. {lifx_emulator → lifx_emulator_app}/api/routers/monitoring.py +1 -1
  15. {lifx_emulator → lifx_emulator_app}/api/routers/scenarios.py +1 -1
  16. lifx_emulator_app/api/services/__init__.py +8 -0
  17. {lifx_emulator → lifx_emulator_app}/api/services/device_service.py +3 -2
  18. lifx_emulator_app/api/static/dashboard.js +588 -0
  19. lifx_emulator_app/api/templates/dashboard.html +357 -0
  20. lifx_emulator/__init__.py +0 -31
  21. lifx_emulator/api/routers/__init__.py +0 -11
  22. lifx_emulator/api/services/__init__.py +0 -8
  23. lifx_emulator/api/templates/dashboard.html +0 -899
  24. lifx_emulator/constants.py +0 -33
  25. lifx_emulator/devices/__init__.py +0 -37
  26. lifx_emulator/devices/device.py +0 -395
  27. lifx_emulator/devices/manager.py +0 -256
  28. lifx_emulator/devices/observers.py +0 -139
  29. lifx_emulator/devices/persistence.py +0 -308
  30. lifx_emulator/devices/state_restorer.py +0 -259
  31. lifx_emulator/devices/state_serializer.py +0 -157
  32. lifx_emulator/devices/states.py +0 -381
  33. lifx_emulator/factories/__init__.py +0 -39
  34. lifx_emulator/factories/builder.py +0 -375
  35. lifx_emulator/factories/default_config.py +0 -158
  36. lifx_emulator/factories/factory.py +0 -252
  37. lifx_emulator/factories/firmware_config.py +0 -77
  38. lifx_emulator/factories/serial_generator.py +0 -82
  39. lifx_emulator/handlers/__init__.py +0 -39
  40. lifx_emulator/handlers/base.py +0 -49
  41. lifx_emulator/handlers/device_handlers.py +0 -322
  42. lifx_emulator/handlers/light_handlers.py +0 -503
  43. lifx_emulator/handlers/multizone_handlers.py +0 -249
  44. lifx_emulator/handlers/registry.py +0 -110
  45. lifx_emulator/handlers/tile_handlers.py +0 -488
  46. lifx_emulator/products/__init__.py +0 -28
  47. lifx_emulator/products/generator.py +0 -1079
  48. lifx_emulator/products/registry.py +0 -1530
  49. lifx_emulator/products/specs.py +0 -284
  50. lifx_emulator/products/specs.yml +0 -386
  51. lifx_emulator/protocol/__init__.py +0 -1
  52. lifx_emulator/protocol/base.py +0 -446
  53. lifx_emulator/protocol/const.py +0 -8
  54. lifx_emulator/protocol/generator.py +0 -1384
  55. lifx_emulator/protocol/header.py +0 -159
  56. lifx_emulator/protocol/packets.py +0 -1351
  57. lifx_emulator/protocol/protocol_types.py +0 -817
  58. lifx_emulator/protocol/serializer.py +0 -379
  59. lifx_emulator/repositories/__init__.py +0 -22
  60. lifx_emulator/repositories/device_repository.py +0 -155
  61. lifx_emulator/repositories/storage_backend.py +0 -107
  62. lifx_emulator/scenarios/__init__.py +0 -22
  63. lifx_emulator/scenarios/manager.py +0 -322
  64. lifx_emulator/scenarios/models.py +0 -112
  65. lifx_emulator/scenarios/persistence.py +0 -241
  66. lifx_emulator/server.py +0 -464
  67. lifx_emulator-2.4.0.dist-info/METADATA +0 -107
  68. lifx_emulator-2.4.0.dist-info/RECORD +0 -62
  69. lifx_emulator-2.4.0.dist-info/entry_points.txt +0 -2
  70. lifx_emulator-2.4.0.dist-info/licenses/LICENSE +0 -35
@@ -1,33 +0,0 @@
1
- """Protocol constants for LIFX LAN Protocol"""
2
-
3
- from typing import Final
4
-
5
- # ============================================================================
6
- # Network Constants
7
- # ============================================================================
8
-
9
- # LIFX UDP port for device communication
10
- LIFX_UDP_PORT: Final[int] = 56700
11
-
12
- # LIFX Protocol version
13
- LIFX_PROTOCOL_VERSION: Final[int] = 1024
14
-
15
- # Header size in bytes
16
- LIFX_HEADER_SIZE: Final[int] = 36
17
-
18
- # Backward compatibility alias
19
- HEADER_SIZE = LIFX_HEADER_SIZE
20
-
21
- # ============================================================================
22
- # Official LIFX Repository URLs
23
- # ============================================================================
24
-
25
- # Official LIFX protocol specification URL
26
- PROTOCOL_URL: Final[str] = (
27
- "https://raw.githubusercontent.com/LIFX/public-protocol/refs/heads/main/protocol.yml"
28
- )
29
-
30
- # Official LIFX products specification URL
31
- PRODUCTS_URL: Final[str] = (
32
- "https://raw.githubusercontent.com/LIFX/products/refs/heads/master/products.json"
33
- )
@@ -1,37 +0,0 @@
1
- """Device management module for LIFX emulator.
2
-
3
- This module contains all device-related functionality including:
4
- - Device core (EmulatedLifxDevice)
5
- - Device manager (DeviceManager, IDeviceManager)
6
- - Device states (DeviceState and related dataclasses)
7
- - Device persistence (async file storage)
8
- - State restoration and serialization
9
- - Device state observers (ActivityObserver, ActivityLogger, PacketEvent, NullObserver)
10
- """
11
-
12
- from lifx_emulator.devices.device import EmulatedLifxDevice
13
- from lifx_emulator.devices.manager import DeviceManager, IDeviceManager
14
- from lifx_emulator.devices.observers import (
15
- ActivityLogger,
16
- ActivityObserver,
17
- NullObserver,
18
- PacketEvent,
19
- )
20
- from lifx_emulator.devices.persistence import (
21
- DEFAULT_STORAGE_DIR,
22
- DevicePersistenceAsyncFile,
23
- )
24
- from lifx_emulator.devices.states import DeviceState
25
-
26
- __all__ = [
27
- "EmulatedLifxDevice",
28
- "DeviceManager",
29
- "IDeviceManager",
30
- "DeviceState",
31
- "DevicePersistenceAsyncFile",
32
- "DEFAULT_STORAGE_DIR",
33
- "ActivityObserver",
34
- "ActivityLogger",
35
- "PacketEvent",
36
- "NullObserver",
37
- ]
@@ -1,395 +0,0 @@
1
- """Device state and emulated device implementation."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import copy
7
- import logging
8
- import time
9
- from typing import Any
10
-
11
- from lifx_emulator.constants import LIFX_HEADER_SIZE
12
- from lifx_emulator.devices.states import DeviceState, TileFramebuffers
13
- from lifx_emulator.handlers import HandlerRegistry, create_default_registry
14
- from lifx_emulator.protocol.header import LifxHeader
15
- from lifx_emulator.protocol.packets import (
16
- Device,
17
- )
18
- from lifx_emulator.protocol.protocol_types import LightHsbk
19
- from lifx_emulator.scenarios import (
20
- HierarchicalScenarioManager,
21
- ScenarioConfig,
22
- get_device_type,
23
- )
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
- # Forward declaration for type hinting
28
- TYPE_CHECKING = False
29
- if TYPE_CHECKING:
30
- from lifx_emulator.devices.persistence import DevicePersistenceAsyncFile
31
-
32
-
33
- class EmulatedLifxDevice:
34
- """Emulated LIFX device with configurable scenarios and state management."""
35
-
36
- def __init__(
37
- self,
38
- device_state: DeviceState,
39
- storage: DevicePersistenceAsyncFile | None = None,
40
- handler_registry: HandlerRegistry | None = None,
41
- scenario_manager: HierarchicalScenarioManager | None = None,
42
- ):
43
- self.state = device_state
44
- # Use provided scenario manager or create a default empty one
45
- if scenario_manager is not None:
46
- self.scenario_manager = scenario_manager
47
- else:
48
- self.scenario_manager = HierarchicalScenarioManager()
49
- self.start_time = time.time()
50
- self.storage = storage
51
-
52
- # Scenario caching for performance (HierarchicalScenarioManager only)
53
- self._cached_scenario: ScenarioConfig | None = None
54
-
55
- # Track background save tasks to prevent garbage collection
56
- self.background_save_tasks: set[asyncio.Task] = set()
57
-
58
- # Use provided registry or create default one
59
- self.handlers = handler_registry or create_default_registry()
60
-
61
- # Pre-allocate response header template for performance (10-15% gain)
62
- # This avoids creating a new LifxHeader object for every response
63
- self._response_header_template = LifxHeader(
64
- source=0,
65
- target=self.state.get_target_bytes(),
66
- sequence=0,
67
- tagged=False,
68
- pkt_type=0,
69
- size=0,
70
- )
71
-
72
- # Initialize multizone colors if needed
73
- # Note: State restoration is handled by StateRestorer in factories
74
- if self.state.has_multizone and self.state.zone_count > 0:
75
- if not self.state.zone_colors:
76
- # Initialize with rainbow pattern using list comprehension
77
- # (performance optimization)
78
- self.state.zone_colors = [
79
- LightHsbk(
80
- hue=int((i / self.state.zone_count) * 65535),
81
- saturation=65535,
82
- brightness=32768,
83
- kelvin=3500,
84
- )
85
- for i in range(self.state.zone_count)
86
- ]
87
-
88
- # Initialize tile state if needed
89
- # Note: Saved tile data is restored by StateRestorer in factories
90
- if self.state.has_matrix and self.state.tile_count > 0:
91
- if not self.state.tile_devices:
92
- for i in range(self.state.tile_count):
93
- zones = self.state.tile_width * self.state.tile_height
94
- tile_colors = [
95
- LightHsbk(hue=0, saturation=0, brightness=32768, kelvin=3500)
96
- for _ in range(zones)
97
- ]
98
-
99
- self.state.tile_devices.append(
100
- {
101
- "accel_meas_x": 0,
102
- "accel_meas_y": 0,
103
- "accel_meas_z": 0,
104
- "user_x": float(i * self.state.tile_width),
105
- "user_y": 0.0,
106
- "width": self.state.tile_width,
107
- "height": self.state.tile_height,
108
- "device_version_vendor": 1,
109
- "device_version_product": self.state.product,
110
- "firmware_build": int(time.time()),
111
- "firmware_version_minor": 70,
112
- "firmware_version_major": 3,
113
- "colors": tile_colors,
114
- }
115
- )
116
-
117
- # Initialize framebuffer storage for each tile (framebuffers 1-7)
118
- # Framebuffer 0 is stored in tile_devices[i]["colors"]
119
- if not self.state.tile_framebuffers:
120
- for i in range(self.state.tile_count):
121
- self.state.tile_framebuffers.append(TileFramebuffers(tile_index=i))
122
-
123
- # Save initial state if persistence is enabled
124
- # This ensures newly created devices are immediately persisted
125
- if self.storage:
126
- self._save_state()
127
-
128
- def get_uptime_ns(self) -> int:
129
- """Calculate current uptime in nanoseconds"""
130
- return int((time.time() - self.start_time) * 1e9)
131
-
132
- def _save_state(self) -> None:
133
- """Save device state asynchronously (non-blocking).
134
-
135
- Creates a background task to save state without blocking the event loop.
136
- The task is tracked to prevent garbage collection.
137
-
138
- Note: Only DevicePersistenceAsyncFile is supported in production. For testing,
139
- you can pass None to disable persistence.
140
- """
141
- if not self.storage:
142
- return
143
-
144
- try:
145
- loop = asyncio.get_running_loop()
146
- task = loop.create_task(self.storage.save_device_state(self.state))
147
- self._track_save_task(task)
148
- except RuntimeError:
149
- # No event loop (shouldn't happen in normal operation)
150
- logger.error("Cannot save state for %s: no event loop", self.state.serial)
151
-
152
- def _track_save_task(self, task: asyncio.Task) -> None:
153
- """Track background save task to prevent garbage collection.
154
-
155
- Args:
156
- task: Save task to track
157
- """
158
- self.background_save_tasks.add(task)
159
- task.add_done_callback(self.background_save_tasks.discard)
160
-
161
- def _get_resolved_scenario(self) -> ScenarioConfig:
162
- """Get resolved scenario configuration with caching.
163
-
164
- Resolves scenario from all applicable scopes and caches the result
165
- for performance.
166
-
167
- Returns:
168
- ScenarioConfig with resolved settings
169
- """
170
- if self._cached_scenario is not None:
171
- return self._cached_scenario
172
-
173
- # Resolve scenario with hierarchical scoping
174
- self._cached_scenario = self.scenario_manager.get_scenario_for_device(
175
- serial=self.state.serial,
176
- device_type=get_device_type(self),
177
- location=self.state.location_label,
178
- group=self.state.group_label,
179
- )
180
- return self._cached_scenario
181
-
182
- def invalidate_scenario_cache(self) -> None:
183
- """Invalidate cached scenario configuration.
184
-
185
- Call this when scenarios are updated at runtime to force
186
- recalculation on the next packet.
187
- """
188
- self._cached_scenario = None
189
-
190
- def _create_response_header(
191
- self, source: int, sequence: int, pkt_type: int, payload_size: int
192
- ) -> LifxHeader:
193
- """Create response header using pre-allocated template (performance).
194
-
195
- This method uses a pre-allocated template and creates a shallow copy,
196
- then updates the fields. This avoids full __init__ and __post_init__
197
- overhead while ensuring each response gets its own header object,
198
- providing ~10% improvement in response generation.
199
-
200
- Args:
201
- source: Source identifier from request
202
- sequence: Sequence number from request
203
- pkt_type: Packet type for response
204
- payload_size: Size of packed payload in bytes
205
-
206
- Returns:
207
- Configured LifxHeader ready to use
208
- """
209
- # Shallow copy of template is faster than full construction with validation
210
- header = copy.copy(self._response_header_template)
211
- # Update fields for this specific response
212
- header.source = source
213
- header.sequence = sequence
214
- header.pkt_type = pkt_type
215
- header.size = LIFX_HEADER_SIZE + payload_size
216
- return header
217
-
218
- def _should_handle_packet(self, pkt_type: int) -> bool:
219
- """Check if device should handle a packet type based on capabilities.
220
-
221
- Args:
222
- pkt_type: Packet type number
223
-
224
- Returns:
225
- True if device should handle, False if should return StateUnhandled
226
- """
227
- # Device.* packets are always handled (2-59)
228
- if 2 <= pkt_type <= 59:
229
- return True
230
-
231
- # Light.* packets (101-149) require light capabilities
232
- # Switches (devices with relays) don't support light operations
233
- if 101 <= pkt_type <= 149:
234
- return not self.state.has_relays
235
-
236
- # MultiZone.* packets (501-512) require multizone capability
237
- if 501 <= pkt_type <= 512:
238
- return self.state.has_multizone
239
-
240
- # Tile.* packets (701-720) require matrix capability
241
- if 701 <= pkt_type <= 720:
242
- return self.state.has_matrix
243
-
244
- # Unknown packets - let handler decide
245
- return True
246
-
247
- def process_packet(
248
- self, header: LifxHeader, packet: Any | None
249
- ) -> list[tuple[LifxHeader, Any]]:
250
- """Process incoming packet and return response packets"""
251
- responses = []
252
-
253
- # Get resolved scenario configuration (cached for performance)
254
- scenario = self._get_resolved_scenario()
255
-
256
- # Check if packet should be dropped (with probabilistic drops)
257
- if not self.scenario_manager.should_respond(header.pkt_type, scenario):
258
- logger.info("Dropping packet type %s per scenario", header.pkt_type)
259
- return responses
260
-
261
- # Check if device should handle this packet type (capability-based filtering)
262
- if not self._should_handle_packet(header.pkt_type):
263
- # Return StateUnhandled for unsupported packet types
264
- state_unhandled = Device.StateUnhandled(unhandled_type=header.pkt_type)
265
- unhandled_payload = state_unhandled.pack()
266
- unhandled_header = self._create_response_header(
267
- header.source,
268
- header.sequence,
269
- state_unhandled.PKT_TYPE,
270
- len(unhandled_payload),
271
- )
272
- responses.append((unhandled_header, state_unhandled))
273
-
274
- # Still send acknowledgment if requested
275
- if header.ack_required:
276
- ack_packet = Device.Acknowledgement()
277
- ack_payload = ack_packet.pack()
278
- ack_header = self._create_response_header(
279
- header.source,
280
- header.sequence,
281
- ack_packet.PKT_TYPE,
282
- len(ack_payload),
283
- )
284
- responses.append((ack_header, ack_packet))
285
-
286
- return responses
287
-
288
- # Update uptime
289
- self.state.uptime_ns = self.get_uptime_ns()
290
-
291
- # Handle acknowledgment (packet type 45, no payload)
292
- if header.ack_required:
293
- ack_packet = Device.Acknowledgement()
294
- ack_payload = ack_packet.pack()
295
- ack_header = self._create_response_header(
296
- header.source,
297
- header.sequence,
298
- ack_packet.PKT_TYPE,
299
- len(ack_payload),
300
- )
301
- # Store header, packet, and pre-packed payload
302
- # (consistent with response format)
303
- responses.append((ack_header, ack_packet, ack_payload))
304
-
305
- # Handle specific packet types - handlers always return list
306
- response_packets = self._handle_packet_type(header, packet)
307
- # Handlers now always return list (empty if no response)
308
- for resp_packet in response_packets:
309
- # Cache packed payload to avoid double packing (performance optimization)
310
- resp_payload = resp_packet.pack()
311
- resp_header = self._create_response_header(
312
- header.source,
313
- header.sequence,
314
- resp_packet.PKT_TYPE,
315
- len(resp_payload),
316
- )
317
- # Store both header and pre-packed payload for error scenario processing
318
- responses.append((resp_header, resp_packet, resp_payload))
319
-
320
- # Apply error scenarios to responses
321
- modified_responses = []
322
- for resp_header, resp_packet, resp_payload in responses:
323
- # Check if we should send malformed packet (truncate payload)
324
- if resp_header.pkt_type in scenario.malformed_packets:
325
- # For malformed packets, truncate the pre-packed payload
326
- truncated_len = len(resp_payload) // 2
327
- resp_payload_modified = resp_payload[:truncated_len]
328
- resp_header.size = LIFX_HEADER_SIZE + truncated_len + 10 # Wrong size
329
- # Convert back to bytes for malformed case
330
- modified_responses.append((resp_header, resp_payload_modified))
331
- logger.info(
332
- "Sending malformed packet type %s (truncated)", resp_header.pkt_type
333
- )
334
- continue
335
-
336
- # Check if we should send invalid field values
337
- if resp_header.pkt_type in scenario.invalid_field_values:
338
- # Corrupt the pre-packed payload
339
- resp_payload_modified = b"\xff" * len(resp_payload)
340
- modified_responses.append((resp_header, resp_payload_modified))
341
- pkt_type = resp_header.pkt_type
342
- logger.info("Sending invalid field values for packet type %s", pkt_type)
343
- continue
344
-
345
- # Normal case: use original packet object (will be packed later by server)
346
- modified_responses.append((resp_header, resp_packet))
347
-
348
- return modified_responses
349
-
350
- def _handle_packet_type(self, header: LifxHeader, packet: Any | None) -> list[Any]:
351
- """Handle specific packet types using registered handlers.
352
-
353
- Returns:
354
- List of response packets (empty list if no response)
355
- """
356
- pkt_type = header.pkt_type
357
-
358
- # Update uptime for this packet
359
- self.state.uptime_ns = self.get_uptime_ns()
360
-
361
- # Find handler for this packet type
362
- handler = self.handlers.get_handler(pkt_type)
363
-
364
- if handler:
365
- # Delegate to handler (always returns list now)
366
- response = handler.handle(self.state, packet, header.res_required)
367
-
368
- # Save state if storage is enabled (for SET operations)
369
- if packet and self.storage:
370
- self._save_state()
371
-
372
- return response
373
- else:
374
- # Unknown/unimplemented packet type
375
- from lifx_emulator.protocol.packets import get_packet_class
376
-
377
- packet_class = get_packet_class(pkt_type)
378
- if packet_class:
379
- logger.info(
380
- "Device %s: Received %s (type %s) but no handler registered",
381
- self.state.serial,
382
- packet_class.__qualname__,
383
- pkt_type,
384
- )
385
- else:
386
- serial = self.state.serial
387
- logger.warning(
388
- "Device %s: Received unknown packet type %s", serial, pkt_type
389
- )
390
-
391
- # Check scenario for StateUnhandled response
392
- scenario = self._get_resolved_scenario()
393
- if scenario.send_unhandled:
394
- return [Device.StateUnhandled(unhandled_type=pkt_type)]
395
- return []