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,256 +0,0 @@
1
- """Device management for LIFX emulator.
2
-
3
- This module provides the DeviceManager class which handles device lifecycle
4
- operations, packet routing, and device lookup. It follows the separation of
5
- concerns principle by extracting domain logic from the network layer.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import logging
11
- from typing import TYPE_CHECKING, Protocol, runtime_checkable
12
-
13
- if TYPE_CHECKING:
14
- from lifx_emulator.devices.device import EmulatedLifxDevice
15
- from lifx_emulator.protocol.header import LifxHeader
16
- from lifx_emulator.repositories import IDeviceRepository
17
- from lifx_emulator.scenarios import HierarchicalScenarioManager
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
-
22
- @runtime_checkable
23
- class IDeviceManager(Protocol):
24
- """Interface for device management operations."""
25
-
26
- def add_device(
27
- self,
28
- device: EmulatedLifxDevice,
29
- scenario_manager: HierarchicalScenarioManager | None = None,
30
- ) -> bool:
31
- """Add a device to the manager.
32
-
33
- Args:
34
- device: The device to add
35
- scenario_manager: Optional scenario manager to share with the device
36
-
37
- Returns:
38
- True if added, False if device with same serial already exists
39
- """
40
- ...
41
-
42
- def remove_device(self, serial: str, storage=None) -> bool:
43
- """Remove a device from the manager.
44
-
45
- Args:
46
- serial: Serial number of device to remove (12 hex chars)
47
- storage: Optional storage backend to delete persistent state
48
-
49
- Returns:
50
- True if removed, False if device not found
51
- """
52
- ...
53
-
54
- def remove_all_devices(self, delete_storage: bool = False, storage=None) -> int:
55
- """Remove all devices from the manager.
56
-
57
- Args:
58
- delete_storage: If True, also delete persistent storage files
59
- storage: Storage backend to use for deletion
60
-
61
- Returns:
62
- Number of devices removed
63
- """
64
- ...
65
-
66
- def get_device(self, serial: str) -> EmulatedLifxDevice | None:
67
- """Get a device by serial number.
68
-
69
- Args:
70
- serial: Serial number (12 hex chars)
71
-
72
- Returns:
73
- Device if found, None otherwise
74
- """
75
- ...
76
-
77
- def get_all_devices(self) -> list[EmulatedLifxDevice]:
78
- """Get all devices.
79
-
80
- Returns:
81
- List of all devices
82
- """
83
- ...
84
-
85
- def count_devices(self) -> int:
86
- """Get the number of devices.
87
-
88
- Returns:
89
- Number of devices in the manager
90
- """
91
- ...
92
-
93
- def resolve_target_devices(self, header: LifxHeader) -> list[EmulatedLifxDevice]:
94
- """Resolve which devices should handle a packet based on the header.
95
-
96
- Args:
97
- header: Parsed LIFX header containing target information
98
-
99
- Returns:
100
- List of devices that should process this packet
101
- """
102
- ...
103
-
104
- def invalidate_all_scenario_caches(self) -> None:
105
- """Invalidate scenario cache for all devices.
106
-
107
- This should be called when scenario configuration changes to ensure
108
- devices reload their scenario settings from the scenario manager.
109
- """
110
- ...
111
-
112
-
113
- class DeviceManager:
114
- """Manages device lifecycle, routing, and lookup operations.
115
-
116
- This class extracts device management logic from EmulatedLifxServer,
117
- providing a clean separation between domain logic and network I/O.
118
- It mirrors the architecture of HierarchicalScenarioManager.
119
- """
120
-
121
- def __init__(self, device_repository: IDeviceRepository):
122
- """Initialize the device manager.
123
-
124
- Args:
125
- device_repository: Repository for device storage and retrieval
126
- """
127
- self._device_repository = device_repository
128
-
129
- def add_device(
130
- self,
131
- device: EmulatedLifxDevice,
132
- scenario_manager: HierarchicalScenarioManager | None = None,
133
- ) -> bool:
134
- """Add a device to the manager.
135
-
136
- Args:
137
- device: The device to add
138
- scenario_manager: Optional scenario manager to share with the device
139
-
140
- Returns:
141
- True if added, False if device with same serial already exists
142
- """
143
- # If device is using HierarchicalScenarioManager, share the provided manager
144
- if scenario_manager is not None:
145
- from lifx_emulator.scenarios import HierarchicalScenarioManager
146
-
147
- if isinstance(device.scenario_manager, HierarchicalScenarioManager):
148
- device.scenario_manager = scenario_manager
149
- device.invalidate_scenario_cache()
150
-
151
- success = self._device_repository.add(device)
152
- if success:
153
- serial = device.state.serial
154
- logger.info("Added device: %s (product=%s)", serial, device.state.product)
155
- return success
156
-
157
- def remove_device(self, serial: str, storage=None) -> bool:
158
- """Remove a device from the manager.
159
-
160
- Args:
161
- serial: Serial number of device to remove (12 hex chars)
162
- storage: Optional storage backend to delete persistent state
163
-
164
- Returns:
165
- True if removed, False if device not found
166
- """
167
- success = self._device_repository.remove(serial)
168
- if success:
169
- logger.info("Removed device: %s", serial)
170
-
171
- # Delete persistent storage if enabled
172
- if storage:
173
- storage.delete_device_state(serial)
174
-
175
- return success
176
-
177
- def remove_all_devices(self, delete_storage: bool = False, storage=None) -> int:
178
- """Remove all devices from the manager.
179
-
180
- Args:
181
- delete_storage: If True, also delete persistent storage files
182
- storage: Storage backend to use for deletion
183
-
184
- Returns:
185
- Number of devices removed
186
- """
187
- # Clear all devices from repository
188
- device_count = self._device_repository.clear()
189
- logger.info("Removed all %s device(s)", device_count)
190
-
191
- # Delete persistent storage if requested
192
- if delete_storage and storage:
193
- deleted = storage.delete_all_device_states()
194
- logger.info("Deleted %s device state(s) from persistent storage", deleted)
195
-
196
- return device_count
197
-
198
- def get_device(self, serial: str) -> EmulatedLifxDevice | None:
199
- """Get a device by serial number.
200
-
201
- Args:
202
- serial: Serial number (12 hex chars)
203
-
204
- Returns:
205
- Device if found, None otherwise
206
- """
207
- return self._device_repository.get(serial)
208
-
209
- def get_all_devices(self) -> list[EmulatedLifxDevice]:
210
- """Get all devices.
211
-
212
- Returns:
213
- List of all devices
214
- """
215
- return self._device_repository.get_all()
216
-
217
- def count_devices(self) -> int:
218
- """Get the number of devices.
219
-
220
- Returns:
221
- Number of devices in the manager
222
- """
223
- return self._device_repository.count()
224
-
225
- def resolve_target_devices(self, header: LifxHeader) -> list[EmulatedLifxDevice]:
226
- """Resolve which devices should handle a packet based on the header.
227
-
228
- Args:
229
- header: Parsed LIFX header containing target information
230
-
231
- Returns:
232
- List of devices that should process this packet
233
- """
234
- target_devices = []
235
-
236
- if header.tagged or header.target == b"\x00" * 8:
237
- # Broadcast to all devices
238
- target_devices = self._device_repository.get_all()
239
- else:
240
- # Specific device - convert target bytes to serial string
241
- # Target is 8 bytes: 6-byte MAC + 2 null bytes
242
- target_serial = header.target[:6].hex()
243
- device = self._device_repository.get(target_serial)
244
- if device:
245
- target_devices = [device]
246
-
247
- return target_devices
248
-
249
- def invalidate_all_scenario_caches(self) -> None:
250
- """Invalidate scenario cache for all devices.
251
-
252
- This should be called when scenario configuration changes to ensure
253
- devices reload their scenario settings from the scenario manager.
254
- """
255
- for device in self._device_repository.get_all():
256
- device.invalidate_scenario_cache()
@@ -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
- }