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,687 @@
1
+ """
2
+ Smart device control tools with async verification.
3
+
4
+ This module provides intelligent device control with domain-specific handling
5
+ and async operation verification through WebSocket monitoring.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Any
11
+
12
+ from ..client.rest_client import HomeAssistantClient
13
+ from ..config import get_global_settings
14
+ from ..utils.operation_manager import get_operation_from_memory, store_pending_operation
15
+ from ..utils.domain_handlers import get_domain_handler
16
+ from ..client.websocket_listener import start_websocket_listener
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class DeviceControlTools:
22
+ """Smart device control tools with async verification."""
23
+
24
+ def __init__(self, client: HomeAssistantClient | None = None):
25
+ """Initialize device control tools."""
26
+ # Only load settings if client not provided
27
+ if client is None:
28
+ self.settings = get_global_settings()
29
+ self.client = HomeAssistantClient()
30
+ else:
31
+ self.settings = None # type: ignore[assignment]
32
+ self.client = client
33
+ self._listener_started = False
34
+
35
+ async def _ensure_websocket_listener(self) -> None:
36
+ """Ensure WebSocket listener is running for async verification."""
37
+ if not self._listener_started:
38
+ try:
39
+ success = await start_websocket_listener()
40
+ if success:
41
+ self._listener_started = True
42
+ logger.info("WebSocket listener started for async verification")
43
+ else:
44
+ logger.warning(
45
+ "Failed to start WebSocket listener - async verification disabled"
46
+ )
47
+ except Exception as e:
48
+ logger.error(f"Error starting WebSocket listener: {e}")
49
+
50
+ async def control_device_smart(
51
+ self,
52
+ entity_id: str,
53
+ action: str,
54
+ parameters: dict[str, Any] | None = None,
55
+ timeout_seconds: int = 10,
56
+ validate_first: bool = True,
57
+ ) -> dict[str, Any]:
58
+ """
59
+ Universal smart device control with async verification.
60
+
61
+ This tool provides intelligent device control with domain-specific
62
+ parameter handling and async operation verification via WebSocket.
63
+
64
+ Args:
65
+ entity_id: Target entity ID (e.g., 'light.living_room')
66
+ action: Action to perform (on, off, toggle, set, etc.)
67
+ parameters: Action-specific parameters (brightness, temperature, etc.)
68
+ timeout_seconds: How long to wait for operation completion
69
+ validate_first: Whether to validate entity exists before action
70
+
71
+ Returns:
72
+ Operation result with follow-up instructions for async checking
73
+ """
74
+ await self._ensure_websocket_listener()
75
+
76
+ try:
77
+ # Handle parameters that might be passed as JSON string
78
+ if parameters and isinstance(parameters, str):
79
+ try:
80
+ import json
81
+
82
+ parameters = json.loads(parameters)
83
+ except json.JSONDecodeError:
84
+ return {
85
+ "entity_id": entity_id,
86
+ "action": action,
87
+ "success": False,
88
+ "error": f"Invalid JSON in parameters: {parameters}",
89
+ "suggestions": [
90
+ "Parameters should be a valid JSON object",
91
+ "Example: {'brightness': 102, 'color_temp': 4000}",
92
+ ],
93
+ }
94
+ # Parse domain from entity ID
95
+ if "." not in entity_id:
96
+ return {
97
+ "entity_id": entity_id,
98
+ "action": action,
99
+ "success": False,
100
+ "error": f"Invalid entity ID format: {entity_id}",
101
+ "suggestions": [
102
+ "Entity ID must be in format 'domain.entity_name'",
103
+ "Use smart_entity_search to find correct entity ID",
104
+ ],
105
+ }
106
+
107
+ domain = entity_id.split(".")[0]
108
+ handler = get_domain_handler(domain)
109
+
110
+ # Validate entity exists if requested
111
+ if validate_first:
112
+ try:
113
+ current_state = await self.client.get_entity_state(entity_id)
114
+ if not current_state:
115
+ return {
116
+ "entity_id": entity_id,
117
+ "action": action,
118
+ "success": False,
119
+ "error": f"Entity not found: {entity_id}",
120
+ "suggestions": [
121
+ "Use smart_entity_search to find the correct entity",
122
+ "Check entity is not disabled in Home Assistant",
123
+ ],
124
+ }
125
+ except Exception as e:
126
+ return {
127
+ "entity_id": entity_id,
128
+ "action": action,
129
+ "success": False,
130
+ "error": f"Cannot verify entity exists: {str(e)}",
131
+ "suggestions": [
132
+ "Check Home Assistant connection",
133
+ "Verify entity ID spelling",
134
+ ],
135
+ }
136
+
137
+ # Validate action for domain
138
+ valid_actions = handler.get("valid_actions", ["on", "off", "toggle"])
139
+ if action not in valid_actions:
140
+ return {
141
+ "entity_id": entity_id,
142
+ "action": action,
143
+ "success": False,
144
+ "error": f"Invalid action '{action}' for domain '{domain}'",
145
+ "valid_actions": valid_actions,
146
+ "suggestions": [
147
+ f"Valid actions for {domain}: {', '.join(valid_actions)}",
148
+ "Use 'toggle' for simple on/off control",
149
+ ],
150
+ }
151
+
152
+ # Build service call
153
+ service_call = self._build_service_call(
154
+ entity_id, domain, action, parameters, handler
155
+ )
156
+
157
+ # Predict expected state after operation
158
+ expected_state = self._predict_expected_state(
159
+ current_state if validate_first else None, action, parameters, domain
160
+ )
161
+
162
+ # Execute service call
163
+ try:
164
+ await self.client.call_service(
165
+ service_call["domain"],
166
+ service_call["service"],
167
+ service_call["data"],
168
+ )
169
+
170
+ # Store operation for async verification
171
+ operation_id = store_pending_operation(
172
+ entity_id=entity_id,
173
+ action=action,
174
+ service_domain=service_call["domain"],
175
+ service_name=service_call["service"],
176
+ service_data=service_call["data"],
177
+ expected_state=expected_state,
178
+ timeout_ms=timeout_seconds * 1000,
179
+ )
180
+
181
+ return {
182
+ "entity_id": entity_id,
183
+ "action": action,
184
+ "parameters": parameters or {},
185
+ "command_sent": True,
186
+ "operation_id": operation_id,
187
+ "status": "pending_verification",
188
+ "message": f"Command sent to {entity_id}. Use get_device_operation_status() to verify completion.",
189
+ "service_call": service_call,
190
+ "expected_state": expected_state,
191
+ "timeout_seconds": timeout_seconds,
192
+ "follow_up": {
193
+ "tool": "get_device_operation_status",
194
+ "parameters": {
195
+ "operation_id": operation_id,
196
+ "timeout_seconds": timeout_seconds,
197
+ },
198
+ },
199
+ "usage_tips": [
200
+ f"Check operation status: get_device_operation_status('{operation_id}')",
201
+ "Operation will auto-complete when device responds",
202
+ f"Timeout in {timeout_seconds} seconds if no response",
203
+ ],
204
+ }
205
+
206
+ except Exception as e:
207
+ return {
208
+ "entity_id": entity_id,
209
+ "action": action,
210
+ "success": False,
211
+ "error": f"Service call failed: {str(e)}",
212
+ "service_call": service_call,
213
+ "suggestions": [
214
+ "Check if entity supports this action",
215
+ "Verify Home Assistant connection",
216
+ "Check Home Assistant logs for details",
217
+ ],
218
+ }
219
+
220
+ except Exception as e:
221
+ logger.error(f"Error in control_device_smart: {e}")
222
+ return {
223
+ "entity_id": entity_id,
224
+ "action": action,
225
+ "success": False,
226
+ "error": f"Unexpected error: {str(e)}",
227
+ "suggestions": [
228
+ "Check entity ID format",
229
+ "Verify Home Assistant connection",
230
+ "Try simpler action like 'toggle'",
231
+ ],
232
+ }
233
+
234
+ def _build_service_call(
235
+ self,
236
+ entity_id: str,
237
+ domain: str,
238
+ action: str,
239
+ parameters: dict[str, Any] | None,
240
+ handler: dict[str, Any],
241
+ ) -> dict[str, Any]:
242
+ """Build Home Assistant service call from action and parameters."""
243
+
244
+ # Basic service mapping
245
+ service_mapping = {
246
+ "on": "turn_on",
247
+ "off": "turn_off",
248
+ "toggle": "toggle",
249
+ "open": "open_cover" if domain == "cover" else "turn_on",
250
+ "close": "close_cover" if domain == "cover" else "turn_off",
251
+ "set": "turn_on" if domain == "light" else "set_temperature",
252
+ }
253
+
254
+ service_name = service_mapping.get(action, action)
255
+
256
+ # Domain-specific service adjustments
257
+ if domain == "climate":
258
+ if action in ["heat", "cool", "auto"]:
259
+ service_name = "set_hvac_mode"
260
+ if not parameters:
261
+ parameters = {}
262
+ parameters["hvac_mode"] = action
263
+ elif action == "set":
264
+ service_name = "set_temperature"
265
+
266
+ elif domain == "media_player":
267
+ if action in ["play", "pause", "stop"]:
268
+ service_name = f"media_{action}"
269
+ elif action == "set":
270
+ service_name = "volume_set"
271
+
272
+ # Build service data
273
+ service_data = {"entity_id": entity_id}
274
+
275
+ # Add parameters based on domain
276
+ if parameters:
277
+ if domain == "light":
278
+ light_params = [
279
+ "brightness",
280
+ "color_temp",
281
+ "rgb_color",
282
+ "effect",
283
+ "kelvin",
284
+ ]
285
+ for param in light_params:
286
+ if param in parameters:
287
+ service_data[param] = parameters[param]
288
+
289
+ elif domain == "climate":
290
+ climate_params = [
291
+ "temperature",
292
+ "target_temp_high",
293
+ "target_temp_low",
294
+ "hvac_mode",
295
+ ]
296
+ for param in climate_params:
297
+ if param in parameters:
298
+ service_data[param] = parameters[param]
299
+
300
+ elif domain == "cover":
301
+ cover_params = ["position", "tilt_position"]
302
+ for param in cover_params:
303
+ if param in parameters:
304
+ service_data[param] = parameters[param]
305
+
306
+ elif domain == "media_player":
307
+ media_params = [
308
+ "volume_level",
309
+ "media_content_id",
310
+ "media_content_type",
311
+ ]
312
+ for param in media_params:
313
+ if param in parameters:
314
+ service_data[param] = parameters[param]
315
+
316
+ # Remove None values
317
+ service_data = {k: v for k, v in service_data.items() if v is not None}
318
+
319
+ return {"domain": domain, "service": service_name, "data": service_data}
320
+
321
+ def _predict_expected_state(
322
+ self,
323
+ current_state: dict[str, Any] | None,
324
+ action: str,
325
+ parameters: dict[str, Any] | None,
326
+ domain: str,
327
+ ) -> dict[str, Any] | None:
328
+ """Predict expected entity state after operation."""
329
+
330
+ expected = {}
331
+
332
+ # State predictions by action
333
+ if action == "on":
334
+ expected["state"] = "on"
335
+ elif action == "off":
336
+ expected["state"] = "off"
337
+ elif action == "toggle":
338
+ if current_state:
339
+ current = current_state.get("state", "off")
340
+ expected["state"] = "off" if current == "on" else "on"
341
+ else:
342
+ # Can't predict toggle without current state
343
+ return None
344
+ elif action == "open":
345
+ expected["state"] = "open"
346
+ elif action == "close":
347
+ expected["state"] = "closed"
348
+
349
+ # Domain-specific attribute predictions
350
+ if parameters:
351
+ if domain == "light" and action in ["on", "set"]:
352
+ if "brightness" in parameters:
353
+ expected["brightness"] = parameters["brightness"]
354
+ if "color_temp" in parameters:
355
+ expected["color_temp"] = parameters["color_temp"]
356
+
357
+ elif domain == "climate" and action in ["set", "heat", "cool", "auto"]:
358
+ if "temperature" in parameters:
359
+ expected["temperature"] = parameters["temperature"]
360
+ if "hvac_mode" in parameters:
361
+ expected["hvac_mode"] = parameters["hvac_mode"]
362
+ elif action in ["heat", "cool", "auto"]:
363
+ expected["hvac_mode"] = action
364
+
365
+ return expected if expected else None
366
+
367
+ async def get_device_operation_status(
368
+ self, operation_id: str, timeout_seconds: int = 10
369
+ ) -> dict[str, Any]:
370
+ """
371
+ Check status of device operation with async verification.
372
+
373
+ This tool checks the status of operations initiated by control_device_smart.
374
+ Results come from real-time WebSocket monitoring of Home Assistant state changes.
375
+
376
+ Args:
377
+ operation_id: Operation ID returned by control_device_smart
378
+ timeout_seconds: Maximum time to wait for completion
379
+
380
+ Returns:
381
+ Operation status with completion details or timeout info
382
+ """
383
+ operation = get_operation_from_memory(operation_id)
384
+
385
+ if not operation:
386
+ return {
387
+ "operation_id": operation_id,
388
+ "success": False,
389
+ "error": "Operation not found or expired",
390
+ "suggestions": [
391
+ "Operation may have been cleaned up after completion",
392
+ "Check operation ID spelling",
393
+ "Use control_device_smart to start new operation",
394
+ ],
395
+ }
396
+
397
+ # Check operation status
398
+ if operation.status.value == "completed":
399
+ return {
400
+ "operation_id": operation_id,
401
+ "status": "completed",
402
+ "success": True,
403
+ "entity_id": operation.entity_id,
404
+ "action": operation.action,
405
+ "final_state": operation.result_state,
406
+ "duration_ms": operation.duration_ms,
407
+ "message": f"Device {operation.entity_id} successfully {operation.action}",
408
+ "verification_method": "websocket_state_change",
409
+ "details": {
410
+ "service_call": {
411
+ "domain": operation.service_domain,
412
+ "service": operation.service_name,
413
+ "data": operation.service_data,
414
+ },
415
+ "expected_state": operation.expected_state,
416
+ "actual_state": operation.result_state,
417
+ },
418
+ }
419
+
420
+ elif operation.status.value == "failed":
421
+ return {
422
+ "operation_id": operation_id,
423
+ "status": "failed",
424
+ "success": False,
425
+ "entity_id": operation.entity_id,
426
+ "action": operation.action,
427
+ "error": operation.error_message,
428
+ "duration_ms": operation.duration_ms,
429
+ "suggestions": [
430
+ "Check if device is available and responding",
431
+ "Verify device supports the requested action",
432
+ "Check Home Assistant logs for error details",
433
+ "Try a simpler action like toggle",
434
+ ],
435
+ }
436
+
437
+ elif operation.status.value == "timeout":
438
+ return {
439
+ "operation_id": operation_id,
440
+ "status": "timeout",
441
+ "success": False,
442
+ "entity_id": operation.entity_id,
443
+ "action": operation.action,
444
+ "error": f"Operation timed out after {operation.timeout_ms}ms",
445
+ "elapsed_ms": operation.elapsed_ms,
446
+ "suggestions": [
447
+ "Device may be slow to respond or offline",
448
+ "Check device connectivity",
449
+ "Try increasing timeout for slow devices",
450
+ "Verify device is powered on",
451
+ ],
452
+ }
453
+
454
+ else: # pending
455
+ return {
456
+ "operation_id": operation_id,
457
+ "status": "pending",
458
+ "entity_id": operation.entity_id,
459
+ "action": operation.action,
460
+ "elapsed_ms": operation.elapsed_ms,
461
+ "timeout_in_ms": operation.timeout_ms,
462
+ "time_remaining_ms": operation.timeout_ms - operation.elapsed_ms,
463
+ "message": f"Waiting for {operation.entity_id} to respond to {operation.action}...",
464
+ "expected_state": operation.expected_state,
465
+ "monitoring": "websocket_state_changes",
466
+ "tips": [
467
+ "Operation will auto-complete when device state changes",
468
+ "Physical devices may take 1-3 seconds to respond",
469
+ "Call this function again to check for updates",
470
+ ],
471
+ }
472
+
473
+ async def bulk_device_control(
474
+ self, operations: list[dict[str, Any]], parallel: bool = True
475
+ ) -> dict[str, Any]:
476
+ """
477
+ Control multiple devices with bulk operation support.
478
+
479
+ Args:
480
+ operations: List of device control operations
481
+ parallel: Whether to execute operations in parallel
482
+
483
+ Returns:
484
+ Bulk operation results
485
+ """
486
+ if not operations:
487
+ return {"success": False, "error": "No operations provided", "results": []}
488
+
489
+ results: list[dict[str, Any]] = []
490
+ operation_ids: list[str] = []
491
+ skipped_operations: list[dict[str, Any]] = []
492
+
493
+ def validate_operation(
494
+ op: Any, index: int
495
+ ) -> tuple[str | None, str | None, str | None]:
496
+ """Validate operation and return (entity_id, action, error) tuple."""
497
+ if not isinstance(op, dict):
498
+ error = f"Operation at index {index} is not a dict: {type(op).__name__}"
499
+ logger.warning(f"Bulk control: {error}")
500
+ return None, None, error
501
+
502
+ entity_id = op.get("entity_id")
503
+ action = op.get("action")
504
+
505
+ missing_fields = []
506
+ if not entity_id:
507
+ missing_fields.append("entity_id")
508
+ if not action:
509
+ missing_fields.append("action")
510
+
511
+ if missing_fields:
512
+ error = (
513
+ f"Operation at index {index} missing required fields: "
514
+ f"{', '.join(missing_fields)}"
515
+ )
516
+ logger.warning(f"Bulk control: {error}")
517
+ return None, None, error
518
+
519
+ return str(entity_id), str(action), None
520
+
521
+ try:
522
+ # Validate all operations first (centralized validation)
523
+ valid_operations: list[tuple[int, dict[str, Any], str, str]] = []
524
+ for i, op in enumerate(operations):
525
+ entity_id, action, error = validate_operation(op, i)
526
+ if error:
527
+ skipped_operations.append(
528
+ {
529
+ "index": i,
530
+ "operation": op,
531
+ "error": error,
532
+ "success": False,
533
+ }
534
+ )
535
+ else:
536
+ # Store (index, original_op, entity_id, action) for execution
537
+ valid_operations.append((i, op, entity_id, action)) # type: ignore[arg-type]
538
+
539
+ # Execute only valid operations
540
+ if parallel:
541
+ # Build tasks for parallel execution
542
+ tasks = []
543
+ for _i, op, entity_id, action in valid_operations:
544
+ task = self.control_device_smart(
545
+ entity_id=entity_id,
546
+ action=action,
547
+ parameters=op.get("parameters"),
548
+ timeout_seconds=op.get("timeout_seconds", 10),
549
+ validate_first=op.get("validate_first", True),
550
+ )
551
+ tasks.append(task)
552
+
553
+ if tasks:
554
+ task_results = await asyncio.gather(*tasks, return_exceptions=True)
555
+
556
+ # Process results and extract operation IDs
557
+ for result in task_results:
558
+ if isinstance(result, Exception):
559
+ results.append(
560
+ {
561
+ "success": False,
562
+ "error": f"Exception during execution: {result!s}",
563
+ }
564
+ )
565
+ elif isinstance(result, dict):
566
+ results.append(result)
567
+ if "operation_id" in result:
568
+ operation_ids.append(result["operation_id"])
569
+
570
+ else:
571
+ # Execute valid operations sequentially
572
+ for _i, op, entity_id, action in valid_operations:
573
+ result = await self.control_device_smart(
574
+ entity_id=entity_id,
575
+ action=action,
576
+ parameters=op.get("parameters"),
577
+ timeout_seconds=op.get("timeout_seconds", 10),
578
+ validate_first=op.get("validate_first", True),
579
+ )
580
+ results.append(result)
581
+
582
+ if "operation_id" in result:
583
+ operation_ids.append(result["operation_id"])
584
+
585
+ # Count successes and failures from executed operations
586
+ successful = len(
587
+ [r for r in results if isinstance(r, dict) and r.get("command_sent")]
588
+ )
589
+ executed_failed = len(results) - successful
590
+ # Total failed includes both execution failures and skipped operations
591
+ total_failed = executed_failed + len(skipped_operations)
592
+
593
+ response: dict[str, Any] = {
594
+ "total_operations": len(operations),
595
+ "successful_commands": successful,
596
+ "failed_commands": total_failed,
597
+ "skipped_operations": len(skipped_operations),
598
+ "execution_mode": "parallel" if parallel else "sequential",
599
+ "operation_ids": operation_ids,
600
+ "results": results,
601
+ "follow_up": (
602
+ {
603
+ "message": (
604
+ f"Use get_bulk_operation_status() to check all "
605
+ f"{len(operation_ids)} operations"
606
+ ),
607
+ "operation_ids": operation_ids,
608
+ }
609
+ if operation_ids
610
+ else None
611
+ ),
612
+ }
613
+
614
+ # Include skipped operation details if any were skipped
615
+ if skipped_operations:
616
+ response["skipped_details"] = skipped_operations
617
+ response["suggestions"] = [
618
+ "Some operations were skipped due to validation errors",
619
+ "Each operation requires 'entity_id' and 'action' fields",
620
+ "Check skipped_details for specific errors",
621
+ "Example format: {'entity_id': 'light.living_room', 'action': 'on'}",
622
+ ]
623
+
624
+ return response
625
+
626
+ except Exception as e:
627
+ logger.error(f"Error in bulk_device_control: {e}")
628
+ return {
629
+ "success": False,
630
+ "error": f"Bulk operation failed: {e!s}",
631
+ "results": results,
632
+ }
633
+
634
+ async def get_bulk_operation_status(
635
+ self, operation_ids: list[str]
636
+ ) -> dict[str, Any]:
637
+ """
638
+ Check status of multiple operations.
639
+
640
+ Args:
641
+ operation_ids: List of operation IDs to check
642
+
643
+ Returns:
644
+ Status summary for all operations
645
+ """
646
+ if not operation_ids:
647
+ return {"success": False, "error": "No operation IDs provided"}
648
+
649
+ # Check all operations
650
+ statuses = []
651
+ for op_id in operation_ids:
652
+ status = await self.get_device_operation_status(op_id)
653
+ statuses.append(status)
654
+
655
+ # Summarize results
656
+ completed = len([s for s in statuses if s.get("status") == "completed"])
657
+ failed = len([s for s in statuses if s.get("status") in ["failed", "timeout"]])
658
+ pending = len([s for s in statuses if s.get("status") == "pending"])
659
+
660
+ return {
661
+ "total_operations": len(operation_ids),
662
+ "completed": completed,
663
+ "failed": failed,
664
+ "pending": pending,
665
+ "all_complete": pending == 0,
666
+ "summary": {
667
+ "success_rate": f"{completed}/{len(operation_ids)}",
668
+ "completion_percentage": (completed / len(operation_ids)) * 100,
669
+ },
670
+ "detailed_results": statuses,
671
+ "recommendations": (
672
+ [
673
+ "Wait a few seconds and check again if operations are pending",
674
+ "Check failed operations for specific error messages",
675
+ "Retry failed operations with different parameters if needed",
676
+ ]
677
+ if pending > 0 or failed > 0
678
+ else ["All operations completed successfully!"]
679
+ ),
680
+ }
681
+
682
+
683
+ def create_device_control_tools(
684
+ client: HomeAssistantClient | None = None,
685
+ ) -> DeviceControlTools:
686
+ """Create device control tools instance."""
687
+ return DeviceControlTools(client)