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,308 @@
1
+ """Async persistent storage with debouncing to avoid blocking event loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from lifx_emulator.state_serializer import (
13
+ deserialize_device_state,
14
+ serialize_device_state,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ DEFAULT_STORAGE_DIR = Path.home() / ".lifx-emulator"
20
+
21
+
22
+ class AsyncDeviceStorage:
23
+ """High-performance async storage with smart debouncing.
24
+
25
+ Implements AsyncStorageProtocol for non-blocking asynchronous I/O.
26
+ Recommended for production use.
27
+
28
+ Features:
29
+ - Per-device debouncing (coalesces rapid changes to same device)
30
+ - Batch writes (groups multiple devices in single flush)
31
+ - Executor-based I/O (no event loop blocking)
32
+ - Adaptive flush (flushes early if queue size threshold met)
33
+ - Task lifecycle management (prevents GC of background tasks)
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ storage_dir: Path | str = DEFAULT_STORAGE_DIR,
39
+ debounce_ms: int = 100,
40
+ batch_size_threshold: int = 50,
41
+ ):
42
+ """Initialize async storage.
43
+
44
+ Args:
45
+ storage_dir: Directory to store device state files
46
+ debounce_ms: Milliseconds to wait before flushing (default: 100ms)
47
+ batch_size_threshold: Flush early if queue exceeds this size (default: 50)
48
+ """
49
+ self.storage_dir = Path(storage_dir)
50
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ self.debounce_ms = debounce_ms
53
+ self.batch_size_threshold = batch_size_threshold
54
+
55
+ # Per-device pending writes (coalescence)
56
+ self.pending: dict[str, dict] = {}
57
+
58
+ # Single-thread executor (serialized writes)
59
+ self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="storage")
60
+
61
+ # Flush task management
62
+ self.flush_task: asyncio.Task | None = None
63
+ self.lock = asyncio.Lock()
64
+
65
+ # Background task tracking (prevents GC)
66
+ self.background_tasks: set[asyncio.Task] = set()
67
+
68
+ # Metrics
69
+ self.writes_queued = 0
70
+ self.writes_executed = 0
71
+ self.flushes = 0
72
+
73
+ logger.debug("Async storage initialized at %s", self.storage_dir)
74
+
75
+ async def save_device_state(self, device_state: Any) -> None:
76
+ """Queue device state for saving (non-blocking).
77
+
78
+ Args:
79
+ device_state: DeviceState instance to persist
80
+ """
81
+ async with self.lock:
82
+ serial = device_state.serial
83
+
84
+ # Coalesce: Latest state wins
85
+ self.pending[serial] = serialize_device_state(device_state)
86
+ self.writes_queued += 1
87
+
88
+ # Adaptive flush: If queue large, flush early
89
+ if len(self.pending) >= self.batch_size_threshold:
90
+ if self.flush_task and not self.flush_task.done():
91
+ self.flush_task.cancel()
92
+
93
+ # Create flush task and track it
94
+ task = asyncio.create_task(self._flush())
95
+ self._track_task(task)
96
+ self.flush_task = task
97
+
98
+ # Otherwise, debounce normally
99
+ elif not self.flush_task or self.flush_task.done():
100
+ # Create flush task and track it
101
+ task = asyncio.create_task(self._flush_after_delay())
102
+ self._track_task(task)
103
+ self.flush_task = task
104
+
105
+ def _track_task(self, task: asyncio.Task) -> None:
106
+ """Track background task to prevent garbage collection.
107
+
108
+ Args:
109
+ task: Task to track
110
+ """
111
+ self.background_tasks.add(task)
112
+ task.add_done_callback(self.background_tasks.discard)
113
+
114
+ async def _flush_after_delay(self) -> None:
115
+ """Wait for debounce period, then flush."""
116
+ try:
117
+ await asyncio.sleep(self.debounce_ms / 1000.0)
118
+ await self._flush()
119
+ except asyncio.CancelledError:
120
+ # Cancelled by adaptive flush - this is normal
121
+ logger.debug("Flush cancelled by adaptive flush")
122
+
123
+ async def _flush(self) -> None:
124
+ """Flush all pending writes to disk."""
125
+ async with self.lock:
126
+ if not self.pending:
127
+ return
128
+
129
+ writes = list(self.pending.items())
130
+ self.pending.clear()
131
+ self.flushes += 1
132
+
133
+ # Execute batch write in background thread
134
+ loop = asyncio.get_running_loop()
135
+ try:
136
+ await loop.run_in_executor(self.executor, self._batch_write, writes)
137
+ self.writes_executed += len(writes)
138
+ logger.debug("Flushed %s device states to disk", len(writes))
139
+ except Exception as e:
140
+ logger.error("Error flushing device states: %s", e, exc_info=True)
141
+
142
+ def _batch_write(self, writes: list[tuple[str, dict]]) -> None:
143
+ """Synchronous batch write (runs in executor).
144
+
145
+ Args:
146
+ writes: List of (serial, state_dict) tuples to write
147
+ """
148
+ for serial, state_dict in writes:
149
+ path = self.storage_dir / f"{serial}.json"
150
+
151
+ # Atomic write: write to temp, then rename
152
+ temp_path = path.with_suffix(".json.tmp")
153
+ try:
154
+ with open(temp_path, "w") as f:
155
+ json.dump(state_dict, f, indent=2)
156
+ temp_path.replace(path) # Atomic on POSIX
157
+ except Exception as e:
158
+ logger.error("Failed to write state for device %s: %s", serial, e)
159
+ if temp_path.exists():
160
+ temp_path.unlink()
161
+
162
+ def load_device_state(self, serial: str) -> dict[str, Any] | None:
163
+ """Load device state from disk (synchronous).
164
+
165
+ Loading only happens at startup, so blocking is acceptable here.
166
+ This method can be called from both sync and async contexts.
167
+
168
+ Args:
169
+ serial: Device serial
170
+
171
+ Returns:
172
+ Dictionary with device state, or None if not found
173
+ """
174
+ return self._sync_load(serial)
175
+
176
+ def _sync_load(self, serial: str) -> dict[str, Any] | None:
177
+ """Synchronous load (runs in executor)."""
178
+ device_path = self.storage_dir / f"{serial}.json"
179
+
180
+ if not device_path.exists():
181
+ logger.debug("No saved state found for device %s", serial)
182
+ return None
183
+
184
+ try:
185
+ with open(device_path) as f:
186
+ state_dict = json.load(f)
187
+
188
+ state_dict = deserialize_device_state(state_dict)
189
+ logger.info("Loaded saved state for device %s", serial)
190
+ return state_dict
191
+
192
+ except Exception as e:
193
+ logger.error("Failed to load state for device %s: %s", serial, e)
194
+ return None
195
+
196
+ def delete_device_state(self, serial: str) -> None:
197
+ """Delete device state from disk (synchronous).
198
+
199
+ Deletion is rare and blocking is acceptable.
200
+
201
+ Args:
202
+ serial: Device serial
203
+ """
204
+ self._sync_delete(serial)
205
+
206
+ def _sync_delete(self, serial: str) -> None:
207
+ """Synchronous delete (runs in executor).
208
+
209
+ Args:
210
+ serial: Device serial
211
+ """
212
+ device_path = self.storage_dir / f"{serial}.json"
213
+
214
+ if device_path.exists():
215
+ try:
216
+ device_path.unlink()
217
+ logger.info("Deleted saved state for device %s", serial)
218
+ except Exception as e:
219
+ logger.error("Failed to delete state for device %s: %s", serial, e)
220
+
221
+ def list_devices(self) -> list[str]:
222
+ """List all devices with saved state (synchronous, safe to call anytime).
223
+
224
+ Returns:
225
+ List of device serials
226
+ """
227
+ serials = []
228
+ for path in self.storage_dir.glob("*.json"):
229
+ # Skip temp files
230
+ if path.suffix == ".tmp":
231
+ continue
232
+ serials.append(path.stem)
233
+ return sorted(serials)
234
+
235
+ def delete_all_device_states(self) -> int:
236
+ """Delete all device states from disk (synchronous).
237
+
238
+ Returns:
239
+ Number of devices deleted
240
+ """
241
+ deleted_count = 0
242
+ for path in self.storage_dir.glob("*.json"):
243
+ # Skip temp files
244
+ if path.suffix == ".tmp":
245
+ continue
246
+ try:
247
+ path.unlink()
248
+ deleted_count += 1
249
+ logger.info("Deleted saved state for device %s", path.stem)
250
+ except Exception as e:
251
+ logger.error("Failed to delete state for device %s: %s", path.stem, e)
252
+
253
+ logger.info("Deleted %s device state(s) from persistent storage", deleted_count)
254
+ return deleted_count
255
+
256
+ async def shutdown(self) -> None:
257
+ """Flush pending writes and shutdown executor.
258
+
259
+ This should be called before the application exits to ensure
260
+ all pending writes are persisted to disk.
261
+ """
262
+ logger.info("Shutting down async storage...")
263
+
264
+ # Cancel pending flush task
265
+ if self.flush_task and not self.flush_task.done():
266
+ self.flush_task.cancel()
267
+ try:
268
+ await self.flush_task
269
+ except asyncio.CancelledError:
270
+ pass
271
+
272
+ # Flush any remaining pending writes
273
+ await self._flush()
274
+
275
+ # Wait for all background tasks to complete
276
+ if self.background_tasks:
277
+ logger.debug(
278
+ f"Waiting for {len(self.background_tasks)} background tasks..."
279
+ )
280
+ await asyncio.gather(*self.background_tasks, return_exceptions=True)
281
+
282
+ # Shutdown executor (non-blocking to avoid hanging on Windows)
283
+ loop = asyncio.get_running_loop()
284
+ await loop.run_in_executor(None, self.executor.shutdown, True)
285
+
286
+ logger.info("Async storage shutdown complete")
287
+
288
+ def get_stats(self) -> dict[str, Any]:
289
+ """Get storage performance statistics.
290
+
291
+ Returns:
292
+ Dictionary with performance metrics
293
+ """
294
+ coalesce_ratio = (
295
+ (1 - (self.writes_executed / self.writes_queued))
296
+ if self.writes_queued > 0
297
+ else 0
298
+ )
299
+
300
+ return {
301
+ "writes_queued": self.writes_queued,
302
+ "writes_executed": self.writes_executed,
303
+ "pending_writes": len(self.pending),
304
+ "flushes": self.flushes,
305
+ "coalesce_ratio": coalesce_ratio,
306
+ "background_tasks": len(self.background_tasks),
307
+ "debounce_ms": self.debounce_ms,
308
+ }
@@ -0,0 +1,33 @@
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
+ )