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,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)
|