lifx-emulator 2.4.0__py3-none-any.whl → 3.0.1__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.
- lifx_emulator-3.0.1.dist-info/METADATA +102 -0
- lifx_emulator-3.0.1.dist-info/RECORD +18 -0
- lifx_emulator-3.0.1.dist-info/entry_points.txt +2 -0
- lifx_emulator_app/__init__.py +10 -0
- {lifx_emulator → lifx_emulator_app}/__main__.py +2 -3
- {lifx_emulator → lifx_emulator_app}/api/__init__.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/app.py +3 -3
- {lifx_emulator → lifx_emulator_app}/api/mappers/__init__.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/mappers/device_mapper.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/models.py +1 -2
- lifx_emulator_app/api/routers/__init__.py +11 -0
- {lifx_emulator → lifx_emulator_app}/api/routers/devices.py +2 -2
- {lifx_emulator → lifx_emulator_app}/api/routers/monitoring.py +1 -1
- {lifx_emulator → lifx_emulator_app}/api/routers/scenarios.py +1 -1
- lifx_emulator_app/api/services/__init__.py +8 -0
- {lifx_emulator → lifx_emulator_app}/api/services/device_service.py +3 -2
- lifx_emulator/__init__.py +0 -31
- lifx_emulator/api/routers/__init__.py +0 -11
- lifx_emulator/api/services/__init__.py +0 -8
- lifx_emulator/constants.py +0 -33
- lifx_emulator/devices/__init__.py +0 -37
- lifx_emulator/devices/device.py +0 -395
- lifx_emulator/devices/manager.py +0 -256
- lifx_emulator/devices/observers.py +0 -139
- lifx_emulator/devices/persistence.py +0 -308
- lifx_emulator/devices/state_restorer.py +0 -259
- lifx_emulator/devices/state_serializer.py +0 -157
- lifx_emulator/devices/states.py +0 -381
- lifx_emulator/factories/__init__.py +0 -39
- lifx_emulator/factories/builder.py +0 -375
- lifx_emulator/factories/default_config.py +0 -158
- lifx_emulator/factories/factory.py +0 -252
- lifx_emulator/factories/firmware_config.py +0 -77
- lifx_emulator/factories/serial_generator.py +0 -82
- lifx_emulator/handlers/__init__.py +0 -39
- lifx_emulator/handlers/base.py +0 -49
- lifx_emulator/handlers/device_handlers.py +0 -322
- lifx_emulator/handlers/light_handlers.py +0 -503
- lifx_emulator/handlers/multizone_handlers.py +0 -249
- lifx_emulator/handlers/registry.py +0 -110
- lifx_emulator/handlers/tile_handlers.py +0 -488
- lifx_emulator/products/__init__.py +0 -28
- lifx_emulator/products/generator.py +0 -1079
- lifx_emulator/products/registry.py +0 -1530
- lifx_emulator/products/specs.py +0 -284
- lifx_emulator/products/specs.yml +0 -386
- lifx_emulator/protocol/__init__.py +0 -1
- lifx_emulator/protocol/base.py +0 -446
- lifx_emulator/protocol/const.py +0 -8
- lifx_emulator/protocol/generator.py +0 -1384
- lifx_emulator/protocol/header.py +0 -159
- lifx_emulator/protocol/packets.py +0 -1351
- lifx_emulator/protocol/protocol_types.py +0 -817
- lifx_emulator/protocol/serializer.py +0 -379
- lifx_emulator/repositories/__init__.py +0 -22
- lifx_emulator/repositories/device_repository.py +0 -155
- lifx_emulator/repositories/storage_backend.py +0 -107
- lifx_emulator/scenarios/__init__.py +0 -22
- lifx_emulator/scenarios/manager.py +0 -322
- lifx_emulator/scenarios/models.py +0 -112
- lifx_emulator/scenarios/persistence.py +0 -241
- lifx_emulator/server.py +0 -464
- lifx_emulator-2.4.0.dist-info/METADATA +0 -107
- lifx_emulator-2.4.0.dist-info/RECORD +0 -62
- lifx_emulator-2.4.0.dist-info/entry_points.txt +0 -2
- lifx_emulator-2.4.0.dist-info/licenses/LICENSE +0 -35
- {lifx_emulator-2.4.0.dist-info → lifx_emulator-3.0.1.dist-info}/WHEEL +0 -0
- {lifx_emulator → lifx_emulator_app}/api/templates/dashboard.html +0 -0
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
"""Observer pattern for activity tracking and event notification.
|
|
2
|
-
|
|
3
|
-
This module implements the Observer pattern to decouple activity tracking
|
|
4
|
-
from the server core, following the Open/Closed Principle.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from collections import deque
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
-
from typing import Any, Protocol
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class PacketEvent:
|
|
16
|
-
"""Represents a packet transmission or reception event.
|
|
17
|
-
|
|
18
|
-
Attributes:
|
|
19
|
-
timestamp: Unix timestamp of the event
|
|
20
|
-
direction: 'rx' for received, 'tx' for transmitted
|
|
21
|
-
packet_type: Numeric packet type identifier
|
|
22
|
-
packet_name: Human-readable packet name
|
|
23
|
-
addr: Network address string (host:port)
|
|
24
|
-
device: Device serial (for tx events) or None
|
|
25
|
-
target: Target identifier (for rx events) or None
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
timestamp: float
|
|
29
|
-
direction: str # 'rx' or 'tx'
|
|
30
|
-
packet_type: int
|
|
31
|
-
packet_name: str
|
|
32
|
-
addr: str
|
|
33
|
-
device: str | None = None # Device serial for tx events
|
|
34
|
-
target: str | None = None # Target for rx events
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class ActivityObserver(Protocol):
|
|
38
|
-
"""Protocol for observers that track packet activity.
|
|
39
|
-
|
|
40
|
-
Observers implementing this protocol can be attached to the server
|
|
41
|
-
to receive notifications of packet events.
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
def on_packet_received(self, event: PacketEvent) -> None:
|
|
45
|
-
"""Called when a packet is received.
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
event: PacketEvent with direction='rx'
|
|
49
|
-
"""
|
|
50
|
-
...
|
|
51
|
-
|
|
52
|
-
def on_packet_sent(self, event: PacketEvent) -> None:
|
|
53
|
-
"""Called when a packet is sent.
|
|
54
|
-
|
|
55
|
-
Args:
|
|
56
|
-
event: PacketEvent with direction='tx'
|
|
57
|
-
"""
|
|
58
|
-
...
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class ActivityLogger:
|
|
62
|
-
"""Observer that logs recent packet activity.
|
|
63
|
-
|
|
64
|
-
Maintains a rolling buffer of recent packet events for monitoring
|
|
65
|
-
and debugging purposes.
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
def __init__(self, max_events: int = 100):
|
|
69
|
-
"""Initialize activity logger.
|
|
70
|
-
|
|
71
|
-
Args:
|
|
72
|
-
max_events: Maximum number of events to retain
|
|
73
|
-
"""
|
|
74
|
-
self.recent_activity: deque[dict[str, Any]] = deque(maxlen=max_events)
|
|
75
|
-
|
|
76
|
-
def on_packet_received(self, event: PacketEvent) -> None:
|
|
77
|
-
"""Record a received packet event.
|
|
78
|
-
|
|
79
|
-
Args:
|
|
80
|
-
event: PacketEvent with direction='rx'
|
|
81
|
-
"""
|
|
82
|
-
self.recent_activity.append(
|
|
83
|
-
{
|
|
84
|
-
"timestamp": event.timestamp,
|
|
85
|
-
"direction": "rx",
|
|
86
|
-
"packet_type": event.packet_type,
|
|
87
|
-
"packet_name": event.packet_name,
|
|
88
|
-
"target": event.target,
|
|
89
|
-
"addr": event.addr,
|
|
90
|
-
}
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
def on_packet_sent(self, event: PacketEvent) -> None:
|
|
94
|
-
"""Record a sent packet event.
|
|
95
|
-
|
|
96
|
-
Args:
|
|
97
|
-
event: PacketEvent with direction='tx'
|
|
98
|
-
"""
|
|
99
|
-
self.recent_activity.append(
|
|
100
|
-
{
|
|
101
|
-
"timestamp": event.timestamp,
|
|
102
|
-
"direction": "tx",
|
|
103
|
-
"packet_type": event.packet_type,
|
|
104
|
-
"packet_name": event.packet_name,
|
|
105
|
-
"device": event.device,
|
|
106
|
-
"addr": event.addr,
|
|
107
|
-
}
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
def get_recent_activity(self) -> list[dict[str, Any]]:
|
|
111
|
-
"""Get list of recent activity events.
|
|
112
|
-
|
|
113
|
-
Returns:
|
|
114
|
-
List of activity event dictionaries
|
|
115
|
-
"""
|
|
116
|
-
return list(self.recent_activity)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
class NullObserver:
|
|
120
|
-
"""No-op observer for when activity tracking is disabled.
|
|
121
|
-
|
|
122
|
-
Allows code to unconditionally call notify without checking for None.
|
|
123
|
-
"""
|
|
124
|
-
|
|
125
|
-
def on_packet_received(self, event: PacketEvent) -> None:
|
|
126
|
-
"""No-op packet received handler.
|
|
127
|
-
|
|
128
|
-
Args:
|
|
129
|
-
event: PacketEvent (ignored)
|
|
130
|
-
"""
|
|
131
|
-
pass
|
|
132
|
-
|
|
133
|
-
def on_packet_sent(self, event: PacketEvent) -> None:
|
|
134
|
-
"""No-op packet sent handler.
|
|
135
|
-
|
|
136
|
-
Args:
|
|
137
|
-
event: PacketEvent (ignored)
|
|
138
|
-
"""
|
|
139
|
-
pass
|
|
@@ -1,308 +0,0 @@
|
|
|
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.devices.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 DevicePersistenceAsyncFile:
|
|
23
|
-
"""High-performance async storage with smart debouncing.
|
|
24
|
-
|
|
25
|
-
Non-blocking asynchronous I/O for device state persistence.
|
|
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
|
-
}
|
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
"""State restoration for devices with persistent storage.
|
|
2
|
-
|
|
3
|
-
This module provides centralized state restoration logic, eliminating
|
|
4
|
-
duplication between factories and device initialization.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import logging
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
from lifx_emulator.devices.device import DeviceState
|
|
13
|
-
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class StateRestorer:
|
|
18
|
-
"""Handles restoration of device state from persistent storage.
|
|
19
|
-
|
|
20
|
-
Consolidates state restoration logic that was previously duplicated
|
|
21
|
-
between factories and device initialization.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
def __init__(self, storage: Any):
|
|
25
|
-
"""Initialize state restorer.
|
|
26
|
-
|
|
27
|
-
Args:
|
|
28
|
-
storage: Storage instance (DeviceStorage or DevicePersistenceAsyncFile)
|
|
29
|
-
"""
|
|
30
|
-
self.storage = storage
|
|
31
|
-
|
|
32
|
-
def restore_if_available(self, state: DeviceState) -> DeviceState:
|
|
33
|
-
"""Restore saved state if available and compatible.
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
state: DeviceState to restore into
|
|
37
|
-
|
|
38
|
-
Returns:
|
|
39
|
-
The same DeviceState instance with restored values
|
|
40
|
-
"""
|
|
41
|
-
if not self.storage:
|
|
42
|
-
return state
|
|
43
|
-
|
|
44
|
-
saved_state = self.storage.load_device_state(state.serial)
|
|
45
|
-
if not saved_state:
|
|
46
|
-
logger.debug("No saved state found for device %s", state.serial)
|
|
47
|
-
return state
|
|
48
|
-
|
|
49
|
-
# Only restore if product matches
|
|
50
|
-
if saved_state.get("product") != state.product:
|
|
51
|
-
logger.warning(
|
|
52
|
-
"Saved state for %s has different product (%s vs %s), skipping restore",
|
|
53
|
-
state.serial,
|
|
54
|
-
saved_state.get("product"),
|
|
55
|
-
state.product,
|
|
56
|
-
)
|
|
57
|
-
return state
|
|
58
|
-
|
|
59
|
-
logger.info("Restoring saved state for device %s", state.serial)
|
|
60
|
-
|
|
61
|
-
# Restore core state
|
|
62
|
-
self._restore_core_state(state, saved_state)
|
|
63
|
-
|
|
64
|
-
# Restore location and group
|
|
65
|
-
self._restore_location_and_group(state, saved_state)
|
|
66
|
-
|
|
67
|
-
# Restore capability-specific state
|
|
68
|
-
self._restore_capability_state(state, saved_state)
|
|
69
|
-
|
|
70
|
-
return state
|
|
71
|
-
|
|
72
|
-
def _restore_core_state(
|
|
73
|
-
self, state: DeviceState, saved_state: dict[str, Any]
|
|
74
|
-
) -> None:
|
|
75
|
-
"""Restore core device state fields.
|
|
76
|
-
|
|
77
|
-
Args:
|
|
78
|
-
state: DeviceState to restore into
|
|
79
|
-
saved_state: Dictionary with saved state values
|
|
80
|
-
"""
|
|
81
|
-
if "label" in saved_state:
|
|
82
|
-
state.core.label = saved_state["label"]
|
|
83
|
-
if "power_level" in saved_state:
|
|
84
|
-
state.core.power_level = saved_state["power_level"]
|
|
85
|
-
if "color" in saved_state:
|
|
86
|
-
state.core.color = saved_state["color"]
|
|
87
|
-
|
|
88
|
-
def _restore_location_and_group(
|
|
89
|
-
self, state: DeviceState, saved_state: dict[str, Any]
|
|
90
|
-
) -> None:
|
|
91
|
-
"""Restore location and group metadata.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
state: DeviceState to restore into
|
|
95
|
-
saved_state: Dictionary with saved state values
|
|
96
|
-
"""
|
|
97
|
-
# Location
|
|
98
|
-
if "location_id" in saved_state:
|
|
99
|
-
state.location.location_id = saved_state["location_id"]
|
|
100
|
-
if "location_label" in saved_state:
|
|
101
|
-
state.location.location_label = saved_state["location_label"]
|
|
102
|
-
if "location_updated_at" in saved_state:
|
|
103
|
-
state.location.location_updated_at = saved_state["location_updated_at"]
|
|
104
|
-
|
|
105
|
-
# Group
|
|
106
|
-
if "group_id" in saved_state:
|
|
107
|
-
state.group.group_id = saved_state["group_id"]
|
|
108
|
-
if "group_label" in saved_state:
|
|
109
|
-
state.group.group_label = saved_state["group_label"]
|
|
110
|
-
if "group_updated_at" in saved_state:
|
|
111
|
-
state.group.group_updated_at = saved_state["group_updated_at"]
|
|
112
|
-
|
|
113
|
-
def _restore_capability_state(
|
|
114
|
-
self, state: DeviceState, saved_state: dict[str, Any]
|
|
115
|
-
) -> None:
|
|
116
|
-
"""Restore capability-specific state.
|
|
117
|
-
|
|
118
|
-
Args:
|
|
119
|
-
state: DeviceState to restore into
|
|
120
|
-
saved_state: Dictionary with saved state values
|
|
121
|
-
"""
|
|
122
|
-
# Infrared
|
|
123
|
-
if (
|
|
124
|
-
state.has_infrared
|
|
125
|
-
and state.infrared
|
|
126
|
-
and "infrared_brightness" in saved_state
|
|
127
|
-
):
|
|
128
|
-
state.infrared.infrared_brightness = saved_state["infrared_brightness"]
|
|
129
|
-
|
|
130
|
-
# HEV
|
|
131
|
-
if state.has_hev and state.hev:
|
|
132
|
-
if "hev_cycle_duration_s" in saved_state:
|
|
133
|
-
state.hev.hev_cycle_duration_s = saved_state["hev_cycle_duration_s"]
|
|
134
|
-
if "hev_cycle_remaining_s" in saved_state:
|
|
135
|
-
state.hev.hev_cycle_remaining_s = saved_state["hev_cycle_remaining_s"]
|
|
136
|
-
if "hev_cycle_last_power" in saved_state:
|
|
137
|
-
state.hev.hev_cycle_last_power = saved_state["hev_cycle_last_power"]
|
|
138
|
-
if "hev_indication" in saved_state:
|
|
139
|
-
state.hev.hev_indication = saved_state["hev_indication"]
|
|
140
|
-
if "hev_last_result" in saved_state:
|
|
141
|
-
state.hev.hev_last_result = saved_state["hev_last_result"]
|
|
142
|
-
|
|
143
|
-
# Multizone
|
|
144
|
-
if state.has_multizone and state.multizone:
|
|
145
|
-
self._restore_multizone_state(state, saved_state)
|
|
146
|
-
|
|
147
|
-
# Matrix (Tile)
|
|
148
|
-
if state.has_matrix and state.matrix:
|
|
149
|
-
self._restore_matrix_state(state, saved_state)
|
|
150
|
-
|
|
151
|
-
def _restore_multizone_state(
|
|
152
|
-
self, state: DeviceState, saved_state: dict[str, Any]
|
|
153
|
-
) -> None:
|
|
154
|
-
"""Restore multizone-specific state.
|
|
155
|
-
|
|
156
|
-
Args:
|
|
157
|
-
state: DeviceState to restore into
|
|
158
|
-
saved_state: Dictionary with saved state values
|
|
159
|
-
"""
|
|
160
|
-
if state.multizone is None:
|
|
161
|
-
return
|
|
162
|
-
|
|
163
|
-
# First restore zone_count from saved state
|
|
164
|
-
# This ensures the device matches what was previously saved
|
|
165
|
-
if "zone_count" in saved_state:
|
|
166
|
-
state.multizone.zone_count = saved_state["zone_count"]
|
|
167
|
-
logger.debug("Restored zone_count: %s", state.multizone.zone_count)
|
|
168
|
-
|
|
169
|
-
# Now restore zone colors if available
|
|
170
|
-
if "zone_colors" in saved_state:
|
|
171
|
-
# Verify zone count matches (should match now that we restored it)
|
|
172
|
-
if len(saved_state["zone_colors"]) == state.multizone.zone_count:
|
|
173
|
-
state.multizone.zone_colors = saved_state["zone_colors"]
|
|
174
|
-
logger.debug("Restored %s zone colors", len(saved_state["zone_colors"]))
|
|
175
|
-
else:
|
|
176
|
-
logger.warning(
|
|
177
|
-
"Zone count mismatch: saved has %s zones, current has %s zones",
|
|
178
|
-
len(saved_state["zone_colors"]),
|
|
179
|
-
state.multizone.zone_count,
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
if "multizone_effect_type" in saved_state:
|
|
183
|
-
state.multizone.effect_type = saved_state["multizone_effect_type"]
|
|
184
|
-
if "multizone_effect_speed" in saved_state:
|
|
185
|
-
state.multizone.effect_speed = saved_state["multizone_effect_speed"]
|
|
186
|
-
|
|
187
|
-
def _restore_matrix_state(
|
|
188
|
-
self, state: DeviceState, saved_state: dict[str, Any]
|
|
189
|
-
) -> None:
|
|
190
|
-
"""Restore matrix (tile) specific state.
|
|
191
|
-
|
|
192
|
-
Args:
|
|
193
|
-
state: DeviceState to restore into
|
|
194
|
-
saved_state: Dictionary with saved state values
|
|
195
|
-
"""
|
|
196
|
-
if state.matrix is None:
|
|
197
|
-
return
|
|
198
|
-
|
|
199
|
-
# First restore tile configuration (count, width, height) from saved state
|
|
200
|
-
# This ensures the device matches what was previously saved
|
|
201
|
-
if "tile_count" in saved_state:
|
|
202
|
-
state.matrix.tile_count = saved_state["tile_count"]
|
|
203
|
-
logger.debug("Restored tile_count: %s", state.matrix.tile_count)
|
|
204
|
-
if "tile_width" in saved_state:
|
|
205
|
-
state.matrix.tile_width = saved_state["tile_width"]
|
|
206
|
-
logger.debug("Restored tile_width: %s", state.matrix.tile_width)
|
|
207
|
-
if "tile_height" in saved_state:
|
|
208
|
-
state.matrix.tile_height = saved_state["tile_height"]
|
|
209
|
-
logger.debug("Restored tile_height: %s", state.matrix.tile_height)
|
|
210
|
-
|
|
211
|
-
# Now restore tile devices if available
|
|
212
|
-
if "tile_devices" in saved_state:
|
|
213
|
-
saved_tiles = saved_state["tile_devices"]
|
|
214
|
-
# Verify tile count matches (should match now that we restored it)
|
|
215
|
-
if len(saved_tiles) == state.matrix.tile_count:
|
|
216
|
-
# Verify all tiles have matching dimensions
|
|
217
|
-
if all(
|
|
218
|
-
t["width"] == state.matrix.tile_width
|
|
219
|
-
and t["height"] == state.matrix.tile_height
|
|
220
|
-
for t in saved_tiles
|
|
221
|
-
):
|
|
222
|
-
state.matrix.tile_devices = saved_tiles
|
|
223
|
-
logger.debug("Restored %s tile devices", len(saved_tiles))
|
|
224
|
-
else:
|
|
225
|
-
logger.warning(
|
|
226
|
-
"Tile dimensions mismatch, skipping tile restoration"
|
|
227
|
-
)
|
|
228
|
-
else:
|
|
229
|
-
logger.warning(
|
|
230
|
-
f"Tile count mismatch: saved has {len(saved_tiles)} tiles, "
|
|
231
|
-
f"current has {state.matrix.tile_count} tiles"
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
if "tile_effect_type" in saved_state:
|
|
235
|
-
state.matrix.effect_type = saved_state["tile_effect_type"]
|
|
236
|
-
if "tile_effect_speed" in saved_state:
|
|
237
|
-
state.matrix.effect_speed = saved_state["tile_effect_speed"]
|
|
238
|
-
if "tile_effect_palette_count" in saved_state:
|
|
239
|
-
state.matrix.effect_palette_count = saved_state["tile_effect_palette_count"]
|
|
240
|
-
if "tile_effect_palette" in saved_state:
|
|
241
|
-
state.matrix.effect_palette = saved_state["tile_effect_palette"]
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
class NullStateRestorer:
|
|
245
|
-
"""No-op state restorer for devices without persistence.
|
|
246
|
-
|
|
247
|
-
Allows code to unconditionally call restore without checking for None.
|
|
248
|
-
"""
|
|
249
|
-
|
|
250
|
-
def restore_if_available(self, state: DeviceState) -> DeviceState:
|
|
251
|
-
"""No-op restoration.
|
|
252
|
-
|
|
253
|
-
Args:
|
|
254
|
-
state: DeviceState (returned unchanged)
|
|
255
|
-
|
|
256
|
-
Returns:
|
|
257
|
-
The same DeviceState instance
|
|
258
|
-
"""
|
|
259
|
-
return state
|