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.
Files changed (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. 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
+ }