ha-mcp-dev 6.3.1.dev137__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.
- ha_mcp/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- tests/test_env_manager.py +336 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory operation storage and management for async device operations.
|
|
3
|
+
|
|
4
|
+
This module handles tracking of device operations that require async verification
|
|
5
|
+
through WebSocket state changes, providing a clean interface for storing and
|
|
6
|
+
retrieving operation status.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OperationStatus(Enum):
|
|
20
|
+
"""Status of device operations."""
|
|
21
|
+
|
|
22
|
+
PENDING = "pending"
|
|
23
|
+
COMPLETED = "completed"
|
|
24
|
+
FAILED = "failed"
|
|
25
|
+
TIMEOUT = "timeout"
|
|
26
|
+
CANCELLED = "cancelled"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DeviceOperation:
|
|
31
|
+
"""Represents a device operation awaiting completion."""
|
|
32
|
+
|
|
33
|
+
operation_id: str
|
|
34
|
+
entity_id: str
|
|
35
|
+
action: str
|
|
36
|
+
service_domain: str
|
|
37
|
+
service_name: str
|
|
38
|
+
service_data: dict[str, Any]
|
|
39
|
+
status: OperationStatus = OperationStatus.PENDING
|
|
40
|
+
start_time: float = field(default_factory=lambda: time.time() * 1000)
|
|
41
|
+
completion_time: float | None = None
|
|
42
|
+
expected_state: dict[str, Any] | None = None
|
|
43
|
+
result_state: dict[str, Any] | None = None
|
|
44
|
+
error_message: str | None = None
|
|
45
|
+
timeout_ms: int = 10000 # 10 second default timeout
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def elapsed_ms(self) -> float:
|
|
49
|
+
"""Get elapsed time in milliseconds."""
|
|
50
|
+
return time.time() * 1000 - self.start_time
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_expired(self) -> bool:
|
|
54
|
+
"""Check if operation has timed out."""
|
|
55
|
+
return self.elapsed_ms > self.timeout_ms
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def duration_ms(self) -> float | None:
|
|
59
|
+
"""Get operation duration in milliseconds."""
|
|
60
|
+
if self.completion_time:
|
|
61
|
+
return self.completion_time - self.start_time
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OperationManager:
|
|
66
|
+
"""Manages in-memory storage of device operations."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, max_operations: int = 1000, cleanup_interval: int = 300):
|
|
69
|
+
"""Initialize operation manager.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
max_operations: Maximum number of operations to keep in memory
|
|
73
|
+
cleanup_interval: How often to clean up expired operations (seconds)
|
|
74
|
+
"""
|
|
75
|
+
self.operations: dict[str, DeviceOperation] = {}
|
|
76
|
+
self.max_operations = max_operations
|
|
77
|
+
self.cleanup_interval = cleanup_interval
|
|
78
|
+
self.last_cleanup = time.time()
|
|
79
|
+
|
|
80
|
+
def create_operation(
|
|
81
|
+
self,
|
|
82
|
+
entity_id: str,
|
|
83
|
+
action: str,
|
|
84
|
+
service_domain: str,
|
|
85
|
+
service_name: str,
|
|
86
|
+
service_data: dict[str, Any],
|
|
87
|
+
expected_state: dict[str, Any] | None = None,
|
|
88
|
+
timeout_ms: int = 10000,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Create a new device operation.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
entity_id: Target entity ID
|
|
94
|
+
action: Action being performed (e.g., 'turn_on', 'set_temperature')
|
|
95
|
+
service_domain: Home Assistant service domain
|
|
96
|
+
service_name: Home Assistant service name
|
|
97
|
+
service_data: Service call data
|
|
98
|
+
expected_state: Expected entity state after operation
|
|
99
|
+
timeout_ms: Operation timeout in milliseconds
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Operation ID for tracking
|
|
103
|
+
"""
|
|
104
|
+
operation_id = str(uuid.uuid4())
|
|
105
|
+
|
|
106
|
+
operation = DeviceOperation(
|
|
107
|
+
operation_id=operation_id,
|
|
108
|
+
entity_id=entity_id,
|
|
109
|
+
action=action,
|
|
110
|
+
service_domain=service_domain,
|
|
111
|
+
service_name=service_name,
|
|
112
|
+
service_data=service_data,
|
|
113
|
+
expected_state=expected_state,
|
|
114
|
+
timeout_ms=timeout_ms,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
self.operations[operation_id] = operation
|
|
118
|
+
self._maybe_cleanup()
|
|
119
|
+
|
|
120
|
+
logger.info(f"Created operation {operation_id} for {entity_id}: {action}")
|
|
121
|
+
return operation_id
|
|
122
|
+
|
|
123
|
+
def get_operation(self, operation_id: str) -> DeviceOperation | None:
|
|
124
|
+
"""Get operation by ID.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
operation_id: Operation ID to retrieve
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
DeviceOperation if found, None otherwise
|
|
131
|
+
"""
|
|
132
|
+
operation = self.operations.get(operation_id)
|
|
133
|
+
|
|
134
|
+
# Check if operation has timed out
|
|
135
|
+
if (
|
|
136
|
+
operation
|
|
137
|
+
and operation.status == OperationStatus.PENDING
|
|
138
|
+
and operation.is_expired
|
|
139
|
+
):
|
|
140
|
+
operation.status = OperationStatus.TIMEOUT
|
|
141
|
+
operation.completion_time = time.time() * 1000
|
|
142
|
+
operation.error_message = (
|
|
143
|
+
f"Operation timed out after {operation.timeout_ms}ms"
|
|
144
|
+
)
|
|
145
|
+
logger.warning(f"Operation {operation_id} timed out")
|
|
146
|
+
|
|
147
|
+
return operation
|
|
148
|
+
|
|
149
|
+
def update_operation_status(
|
|
150
|
+
self,
|
|
151
|
+
operation_id: str,
|
|
152
|
+
status: OperationStatus,
|
|
153
|
+
result_state: dict[str, Any] | None = None,
|
|
154
|
+
error_message: str | None = None,
|
|
155
|
+
) -> bool:
|
|
156
|
+
"""Update operation status.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
operation_id: Operation ID to update
|
|
160
|
+
status: New status
|
|
161
|
+
result_state: Final entity state (for completed operations)
|
|
162
|
+
error_message: Error message (for failed operations)
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if operation was found and updated
|
|
166
|
+
"""
|
|
167
|
+
operation = self.operations.get(operation_id)
|
|
168
|
+
if not operation:
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
operation.status = status
|
|
172
|
+
operation.completion_time = time.time() * 1000
|
|
173
|
+
|
|
174
|
+
if result_state:
|
|
175
|
+
operation.result_state = result_state
|
|
176
|
+
if error_message:
|
|
177
|
+
operation.error_message = error_message
|
|
178
|
+
|
|
179
|
+
logger.info(f"Updated operation {operation_id} status to {status.value}")
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
def get_pending_operations_for_entity(
|
|
183
|
+
self, entity_id: str
|
|
184
|
+
) -> list[DeviceOperation]:
|
|
185
|
+
"""Get all pending operations for a specific entity.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
entity_id: Entity ID to search for
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of pending operations for the entity
|
|
192
|
+
"""
|
|
193
|
+
pending_ops = []
|
|
194
|
+
for operation in self.operations.values():
|
|
195
|
+
if (
|
|
196
|
+
operation.entity_id == entity_id
|
|
197
|
+
and operation.status == OperationStatus.PENDING
|
|
198
|
+
and not operation.is_expired
|
|
199
|
+
):
|
|
200
|
+
pending_ops.append(operation)
|
|
201
|
+
|
|
202
|
+
return pending_ops
|
|
203
|
+
|
|
204
|
+
def process_state_change(
|
|
205
|
+
self, entity_id: str, new_state: dict[str, Any]
|
|
206
|
+
) -> list[str]:
|
|
207
|
+
"""Process a state change event and update matching operations.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
entity_id: Entity that changed state
|
|
211
|
+
new_state: New entity state
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of operation IDs that were updated
|
|
215
|
+
"""
|
|
216
|
+
updated_operations = []
|
|
217
|
+
pending_ops = self.get_pending_operations_for_entity(entity_id)
|
|
218
|
+
|
|
219
|
+
for operation in pending_ops:
|
|
220
|
+
if self._matches_expected_state(operation, new_state):
|
|
221
|
+
# Operation completed successfully
|
|
222
|
+
self.update_operation_status(
|
|
223
|
+
operation.operation_id,
|
|
224
|
+
OperationStatus.COMPLETED,
|
|
225
|
+
result_state=new_state,
|
|
226
|
+
)
|
|
227
|
+
updated_operations.append(operation.operation_id)
|
|
228
|
+
logger.info(
|
|
229
|
+
f"Operation {operation.operation_id} completed for {entity_id}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
elif new_state.get("state") == "unavailable":
|
|
233
|
+
# Device became unavailable - mark as failed
|
|
234
|
+
self.update_operation_status(
|
|
235
|
+
operation.operation_id,
|
|
236
|
+
OperationStatus.FAILED,
|
|
237
|
+
error_message="Device became unavailable",
|
|
238
|
+
)
|
|
239
|
+
updated_operations.append(operation.operation_id)
|
|
240
|
+
logger.warning(
|
|
241
|
+
f"Operation {operation.operation_id} failed - device unavailable"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return updated_operations
|
|
245
|
+
|
|
246
|
+
def _matches_expected_state(
|
|
247
|
+
self, operation: DeviceOperation, new_state: dict[str, Any]
|
|
248
|
+
) -> bool:
|
|
249
|
+
"""Check if new state matches operation's expected outcome.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
operation: Operation to check
|
|
253
|
+
new_state: New entity state
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if state matches expected outcome
|
|
257
|
+
"""
|
|
258
|
+
# If no expected state specified, any non-unavailable state counts as success
|
|
259
|
+
if not operation.expected_state:
|
|
260
|
+
return new_state.get("state") != "unavailable"
|
|
261
|
+
|
|
262
|
+
# Check expected state attributes
|
|
263
|
+
for key, expected_value in operation.expected_state.items():
|
|
264
|
+
if key == "state":
|
|
265
|
+
if new_state.get("state") != expected_value:
|
|
266
|
+
return False
|
|
267
|
+
elif key in new_state.get("attributes", {}):
|
|
268
|
+
if new_state["attributes"][key] != expected_value:
|
|
269
|
+
return False
|
|
270
|
+
else:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
def cancel_operation(self, operation_id: str) -> bool:
|
|
276
|
+
"""Cancel a pending operation.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
operation_id: Operation ID to cancel
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
True if operation was found and cancelled
|
|
283
|
+
"""
|
|
284
|
+
return self.update_operation_status(
|
|
285
|
+
operation_id,
|
|
286
|
+
OperationStatus.CANCELLED,
|
|
287
|
+
error_message="Operation cancelled by user",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def get_operations_summary(self) -> dict[str, Any]:
|
|
291
|
+
"""Get summary of all operations.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Dictionary with operation statistics
|
|
295
|
+
"""
|
|
296
|
+
total = len(self.operations)
|
|
297
|
+
by_status = {}
|
|
298
|
+
|
|
299
|
+
for status in OperationStatus:
|
|
300
|
+
by_status[status.value] = len(
|
|
301
|
+
[op for op in self.operations.values() if op.status == status]
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Count expired pending operations
|
|
305
|
+
expired_pending = len(
|
|
306
|
+
[
|
|
307
|
+
op
|
|
308
|
+
for op in self.operations.values()
|
|
309
|
+
if op.status == OperationStatus.PENDING and op.is_expired
|
|
310
|
+
]
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
"total_operations": total,
|
|
315
|
+
"by_status": by_status,
|
|
316
|
+
"expired_pending": expired_pending,
|
|
317
|
+
"memory_usage_mb": self._estimate_memory_usage(),
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
def _estimate_memory_usage(self) -> float:
|
|
321
|
+
"""Estimate memory usage in MB (rough approximation)."""
|
|
322
|
+
# Very rough estimate: ~1KB per operation
|
|
323
|
+
return len(self.operations) * 1024 / (1024 * 1024)
|
|
324
|
+
|
|
325
|
+
def cleanup_expired_operations(self, force: bool = False) -> None:
|
|
326
|
+
"""Clean up expired and completed operations.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
force: Force cleanup regardless of interval
|
|
330
|
+
"""
|
|
331
|
+
current_time = time.time()
|
|
332
|
+
|
|
333
|
+
if not force and (current_time - self.last_cleanup) < self.cleanup_interval:
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
initial_count = len(self.operations)
|
|
337
|
+
|
|
338
|
+
# Remove completed operations older than 5 minutes
|
|
339
|
+
# Remove failed/cancelled operations older than 1 minute
|
|
340
|
+
# Remove expired pending operations
|
|
341
|
+
to_remove = []
|
|
342
|
+
|
|
343
|
+
for op_id, operation in self.operations.items():
|
|
344
|
+
age_seconds = (current_time * 1000 - operation.start_time) / 1000
|
|
345
|
+
|
|
346
|
+
if operation.status == OperationStatus.COMPLETED and age_seconds > 300:
|
|
347
|
+
to_remove.append(op_id)
|
|
348
|
+
elif (
|
|
349
|
+
operation.status in [OperationStatus.FAILED, OperationStatus.CANCELLED]
|
|
350
|
+
and age_seconds > 60
|
|
351
|
+
):
|
|
352
|
+
to_remove.append(op_id)
|
|
353
|
+
elif operation.status == OperationStatus.PENDING and operation.is_expired:
|
|
354
|
+
# Mark as timeout first
|
|
355
|
+
operation.status = OperationStatus.TIMEOUT
|
|
356
|
+
operation.completion_time = current_time * 1000
|
|
357
|
+
to_remove.append(op_id)
|
|
358
|
+
|
|
359
|
+
# Remove operations
|
|
360
|
+
for op_id in to_remove:
|
|
361
|
+
del self.operations[op_id]
|
|
362
|
+
|
|
363
|
+
# If still over limit, remove oldest completed operations
|
|
364
|
+
if len(self.operations) > self.max_operations:
|
|
365
|
+
completed_ops = [
|
|
366
|
+
(op_id, op)
|
|
367
|
+
for op_id, op in self.operations.items()
|
|
368
|
+
if op.status == OperationStatus.COMPLETED
|
|
369
|
+
]
|
|
370
|
+
completed_ops.sort(key=lambda x: x[1].completion_time or 0)
|
|
371
|
+
|
|
372
|
+
excess = len(self.operations) - self.max_operations
|
|
373
|
+
for op_id, _ in completed_ops[:excess]:
|
|
374
|
+
del self.operations[op_id]
|
|
375
|
+
|
|
376
|
+
removed_count = initial_count - len(self.operations)
|
|
377
|
+
if removed_count > 0:
|
|
378
|
+
logger.info(f"Cleaned up {removed_count} expired operations")
|
|
379
|
+
|
|
380
|
+
self.last_cleanup = current_time
|
|
381
|
+
|
|
382
|
+
def _maybe_cleanup(self) -> None:
|
|
383
|
+
"""Maybe perform cleanup if needed."""
|
|
384
|
+
if len(self.operations) > self.max_operations * 0.8:
|
|
385
|
+
self.cleanup_expired_operations()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Global operation manager instance
|
|
389
|
+
_operation_manager = None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def get_operation_manager() -> OperationManager:
|
|
393
|
+
"""Get the global operation manager instance."""
|
|
394
|
+
global _operation_manager
|
|
395
|
+
if _operation_manager is None:
|
|
396
|
+
_operation_manager = OperationManager()
|
|
397
|
+
return _operation_manager
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# Convenience functions for external use
|
|
401
|
+
def store_pending_operation(
|
|
402
|
+
entity_id: str,
|
|
403
|
+
action: str,
|
|
404
|
+
service_domain: str,
|
|
405
|
+
service_name: str,
|
|
406
|
+
service_data: dict[str, Any],
|
|
407
|
+
expected_state: dict[str, Any] | None = None,
|
|
408
|
+
timeout_ms: int = 10000,
|
|
409
|
+
) -> str:
|
|
410
|
+
"""Store a new pending operation."""
|
|
411
|
+
manager = get_operation_manager()
|
|
412
|
+
return manager.create_operation(
|
|
413
|
+
entity_id,
|
|
414
|
+
action,
|
|
415
|
+
service_domain,
|
|
416
|
+
service_name,
|
|
417
|
+
service_data,
|
|
418
|
+
expected_state,
|
|
419
|
+
timeout_ms,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def get_operation_from_memory(operation_id: str) -> DeviceOperation | None:
|
|
424
|
+
"""Get operation from memory by ID."""
|
|
425
|
+
manager = get_operation_manager()
|
|
426
|
+
return manager.get_operation(operation_id)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def update_pending_operations(entity_id: str, new_state: dict[str, Any]) -> list[str]:
|
|
430
|
+
"""Update pending operations based on state change."""
|
|
431
|
+
manager = get_operation_manager()
|
|
432
|
+
return manager.process_state_change(entity_id, new_state)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def get_pending_operations() -> dict[str, DeviceOperation]:
|
|
436
|
+
"""Get all pending operations."""
|
|
437
|
+
manager = get_operation_manager()
|
|
438
|
+
return {
|
|
439
|
+
op_id: op
|
|
440
|
+
for op_id, op in manager.operations.items()
|
|
441
|
+
if op.status == OperationStatus.PENDING and not op.is_expired
|
|
442
|
+
}
|