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,924 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Home Assistant HTTP client with authentication and error handling.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ..config import get_global_settings
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HomeAssistantError(Exception):
|
|
18
|
+
"""Base exception for Home Assistant API errors."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HomeAssistantConnectionError(HomeAssistantError):
|
|
24
|
+
"""Connection error to Home Assistant."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HomeAssistantAuthError(HomeAssistantError):
|
|
30
|
+
"""Authentication error with Home Assistant."""
|
|
31
|
+
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HomeAssistantAPIError(HomeAssistantError):
|
|
36
|
+
"""API error from Home Assistant."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
message: str,
|
|
41
|
+
status_code: int | None = None,
|
|
42
|
+
response_data: dict[str, Any] | None = None,
|
|
43
|
+
):
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status_code = status_code
|
|
46
|
+
self.response_data = response_data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HomeAssistantClient:
|
|
50
|
+
"""Authenticated HTTP client for Home Assistant API."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
base_url: str | None = None,
|
|
55
|
+
token: str | None = None,
|
|
56
|
+
timeout: int | None = None,
|
|
57
|
+
):
|
|
58
|
+
"""
|
|
59
|
+
Initialize Home Assistant client.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
base_url: Home Assistant URL (defaults to config)
|
|
63
|
+
token: Long-lived access token (defaults to config)
|
|
64
|
+
timeout: Request timeout in seconds (defaults to config)
|
|
65
|
+
"""
|
|
66
|
+
# Only load settings if we need to use fallback values
|
|
67
|
+
if base_url is None or token is None:
|
|
68
|
+
settings = get_global_settings()
|
|
69
|
+
self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
|
|
70
|
+
self.token = token or settings.homeassistant_token
|
|
71
|
+
self.timeout = timeout if timeout is not None else settings.timeout
|
|
72
|
+
else:
|
|
73
|
+
# All required parameters provided, use them directly without loading settings
|
|
74
|
+
self.base_url = base_url.rstrip("/")
|
|
75
|
+
self.token = token
|
|
76
|
+
self.timeout = timeout if timeout is not None else 30 # Default timeout
|
|
77
|
+
|
|
78
|
+
# Create HTTP client with authentication headers
|
|
79
|
+
self.httpx_client = httpx.AsyncClient(
|
|
80
|
+
base_url=f"{self.base_url}/api",
|
|
81
|
+
headers={
|
|
82
|
+
"Authorization": f"Bearer {self.token}",
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
},
|
|
85
|
+
timeout=httpx.Timeout(self.timeout),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
logger.info(f"Initialized Home Assistant client for {self.base_url}")
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> 'HomeAssistantClient':
|
|
91
|
+
"""Async context manager entry."""
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
95
|
+
"""Async context manager exit."""
|
|
96
|
+
await self.close()
|
|
97
|
+
|
|
98
|
+
async def close(self) -> None:
|
|
99
|
+
"""Close HTTP client."""
|
|
100
|
+
await self.httpx_client.aclose()
|
|
101
|
+
logger.debug("Closed Home Assistant client")
|
|
102
|
+
|
|
103
|
+
async def _request(self, method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
104
|
+
"""
|
|
105
|
+
Make authenticated request to Home Assistant API.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
method: HTTP method (GET, POST, etc.)
|
|
109
|
+
endpoint: API endpoint (without /api prefix)
|
|
110
|
+
**kwargs: Additional arguments for httpx request
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Response data as dictionary
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
HomeAssistantConnectionError: Connection failed
|
|
117
|
+
HomeAssistantAuthError: Authentication failed
|
|
118
|
+
HomeAssistantAPIError: API error
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
response = await self.httpx_client.request(method, endpoint, **kwargs)
|
|
122
|
+
|
|
123
|
+
# Handle authentication errors
|
|
124
|
+
if response.status_code == 401:
|
|
125
|
+
raise HomeAssistantAuthError("Invalid authentication token")
|
|
126
|
+
|
|
127
|
+
# Handle other HTTP errors
|
|
128
|
+
if response.status_code >= 400:
|
|
129
|
+
try:
|
|
130
|
+
error_data = response.json()
|
|
131
|
+
except Exception:
|
|
132
|
+
error_data = {"message": response.text}
|
|
133
|
+
|
|
134
|
+
raise HomeAssistantAPIError(
|
|
135
|
+
f"API error: {response.status_code} - {error_data.get('message', 'Unknown error')}",
|
|
136
|
+
status_code=response.status_code,
|
|
137
|
+
response_data=error_data,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Parse JSON response
|
|
141
|
+
try:
|
|
142
|
+
result: dict[str, Any] = response.json()
|
|
143
|
+
return result
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
# Some endpoints return empty responses
|
|
146
|
+
return {}
|
|
147
|
+
|
|
148
|
+
except httpx.ConnectError as e:
|
|
149
|
+
raise HomeAssistantConnectionError(
|
|
150
|
+
f"Failed to connect to Home Assistant: {e}"
|
|
151
|
+
) from e
|
|
152
|
+
except httpx.TimeoutException as e:
|
|
153
|
+
raise HomeAssistantConnectionError(f"Request timeout: {e}") from e
|
|
154
|
+
except httpx.HTTPError as e:
|
|
155
|
+
raise HomeAssistantConnectionError(f"HTTP error: {e}") from e
|
|
156
|
+
|
|
157
|
+
async def get_config(self) -> dict[str, Any]:
|
|
158
|
+
"""Get Home Assistant configuration."""
|
|
159
|
+
logger.debug("Fetching Home Assistant configuration")
|
|
160
|
+
return await self._request("GET", "/config")
|
|
161
|
+
|
|
162
|
+
async def get_states(self) -> list[dict[str, Any]]:
|
|
163
|
+
"""Get all entity states."""
|
|
164
|
+
logger.debug("Fetching all entity states")
|
|
165
|
+
result = await self._request("GET", "/states")
|
|
166
|
+
if isinstance(result, list):
|
|
167
|
+
return result
|
|
168
|
+
else:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
async def get_entity_state(self, entity_id: str) -> dict[str, Any]:
|
|
172
|
+
"""
|
|
173
|
+
Get specific entity state.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
entity_id: Entity ID (e.g., 'light.living_room')
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Entity state data
|
|
180
|
+
"""
|
|
181
|
+
logger.debug(f"Fetching state for entity: {entity_id}")
|
|
182
|
+
return await self._request("GET", f"/states/{entity_id}")
|
|
183
|
+
|
|
184
|
+
async def set_entity_state(
|
|
185
|
+
self, entity_id: str, state: str, attributes: dict[str, Any] | None = None
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
"""
|
|
188
|
+
Set entity state.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
entity_id: Entity ID
|
|
192
|
+
state: New state value
|
|
193
|
+
attributes: Optional attributes dictionary
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Updated entity state
|
|
197
|
+
"""
|
|
198
|
+
logger.debug(f"Setting state for entity {entity_id} to {state}")
|
|
199
|
+
|
|
200
|
+
payload: dict[str, Any] = {"state": state}
|
|
201
|
+
if attributes:
|
|
202
|
+
payload["attributes"] = attributes
|
|
203
|
+
|
|
204
|
+
return await self._request("POST", f"/states/{entity_id}", json=payload)
|
|
205
|
+
|
|
206
|
+
async def call_service(
|
|
207
|
+
self, domain: str, service: str, data: dict[str, Any] | None = None,
|
|
208
|
+
return_response: bool = False
|
|
209
|
+
) -> list[dict[str, Any]] | dict[str, Any]:
|
|
210
|
+
"""
|
|
211
|
+
Call Home Assistant service.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
domain: Service domain (e.g., 'light', 'climate')
|
|
215
|
+
service: Service name (e.g., 'turn_on', 'set_temperature')
|
|
216
|
+
data: Optional service data
|
|
217
|
+
return_response: If True, returns the service response data (for services
|
|
218
|
+
that support SupportsResponse.ONLY or SupportsResponse.OPTIONAL)
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Service response data - list of affected states normally, or dict with
|
|
222
|
+
service response if return_response=True
|
|
223
|
+
"""
|
|
224
|
+
logger.debug(f"Calling service {domain}.{service} (return_response={return_response})")
|
|
225
|
+
|
|
226
|
+
payload = data or {}
|
|
227
|
+
|
|
228
|
+
# Build query params for return_response
|
|
229
|
+
params = {}
|
|
230
|
+
if return_response:
|
|
231
|
+
params["return_response"] = "true"
|
|
232
|
+
|
|
233
|
+
result = await self._request(
|
|
234
|
+
"POST", f"/services/{domain}/{service}", json=payload, params=params if params else None
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# When return_response is True, HA returns a dict with service_response key
|
|
238
|
+
if return_response:
|
|
239
|
+
if isinstance(result, dict):
|
|
240
|
+
return result
|
|
241
|
+
return {"service_response": result}
|
|
242
|
+
|
|
243
|
+
# Normal behavior: return list of affected states
|
|
244
|
+
if isinstance(result, list):
|
|
245
|
+
return result
|
|
246
|
+
else:
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
async def get_services(self) -> dict[str, Any]:
|
|
250
|
+
"""Get all available services."""
|
|
251
|
+
logger.debug("Fetching available services")
|
|
252
|
+
return await self._request("GET", "/services")
|
|
253
|
+
|
|
254
|
+
async def get_history(
|
|
255
|
+
self,
|
|
256
|
+
entity_id: str | None = None,
|
|
257
|
+
start_time: str | None = None,
|
|
258
|
+
end_time: str | None = None,
|
|
259
|
+
) -> list[list[dict[str, Any]]]:
|
|
260
|
+
"""
|
|
261
|
+
Get historical data.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
entity_id: Optional entity ID to filter
|
|
265
|
+
start_time: Optional start time (ISO format)
|
|
266
|
+
end_time: Optional end time (ISO format)
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Historical data
|
|
270
|
+
"""
|
|
271
|
+
logger.debug(f"Fetching history for entity: {entity_id}")
|
|
272
|
+
|
|
273
|
+
params = {}
|
|
274
|
+
if start_time:
|
|
275
|
+
params["start_time"] = start_time
|
|
276
|
+
if end_time:
|
|
277
|
+
params["end_time"] = end_time
|
|
278
|
+
|
|
279
|
+
endpoint = "/history/period"
|
|
280
|
+
if entity_id:
|
|
281
|
+
endpoint += f"/{entity_id}"
|
|
282
|
+
|
|
283
|
+
result = await self._request("GET", endpoint, params=params)
|
|
284
|
+
if isinstance(result, list):
|
|
285
|
+
return result
|
|
286
|
+
else:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
async def get_logbook(
|
|
290
|
+
self,
|
|
291
|
+
entity_id: str | None = None,
|
|
292
|
+
start_time: str | None = None,
|
|
293
|
+
end_time: str | None = None,
|
|
294
|
+
) -> list[dict[str, Any]]:
|
|
295
|
+
"""
|
|
296
|
+
Get logbook entries.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
entity_id: Optional entity ID to filter
|
|
300
|
+
start_time: Optional start time (ISO format) - used as URL path component
|
|
301
|
+
end_time: Optional end time (ISO format) - used as query parameter
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Logbook entries
|
|
305
|
+
"""
|
|
306
|
+
logger.debug(f"Fetching logbook entries for entity: {entity_id}, start: {start_time}, end: {end_time}")
|
|
307
|
+
|
|
308
|
+
# Build endpoint - start_time goes in URL path if provided
|
|
309
|
+
if start_time:
|
|
310
|
+
endpoint = f"/logbook/{start_time}"
|
|
311
|
+
else:
|
|
312
|
+
endpoint = "/logbook"
|
|
313
|
+
|
|
314
|
+
# Build query parameters
|
|
315
|
+
params = {}
|
|
316
|
+
if entity_id:
|
|
317
|
+
params["entity"] = entity_id
|
|
318
|
+
if end_time:
|
|
319
|
+
params["end_time"] = end_time
|
|
320
|
+
|
|
321
|
+
result = await self._request("GET", endpoint, params=params)
|
|
322
|
+
if isinstance(result, list):
|
|
323
|
+
return result
|
|
324
|
+
else:
|
|
325
|
+
return []
|
|
326
|
+
|
|
327
|
+
async def fire_event(
|
|
328
|
+
self, event_type: str, data: dict[str, Any] | None = None
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
"""
|
|
331
|
+
Fire Home Assistant event.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
event_type: Event type name
|
|
335
|
+
data: Optional event data
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Event response
|
|
339
|
+
"""
|
|
340
|
+
logger.debug(f"Firing event: {event_type}")
|
|
341
|
+
|
|
342
|
+
payload = data or {}
|
|
343
|
+
return await self._request("POST", f"/events/{event_type}", json=payload)
|
|
344
|
+
|
|
345
|
+
async def render_template(self, template: str) -> str:
|
|
346
|
+
"""
|
|
347
|
+
Render Home Assistant template.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
template: Template string
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Rendered template
|
|
354
|
+
"""
|
|
355
|
+
logger.debug("Rendering template")
|
|
356
|
+
|
|
357
|
+
payload = {"template": template}
|
|
358
|
+
response = await self._request("POST", "/template", json=payload)
|
|
359
|
+
result = response.get("result")
|
|
360
|
+
return str(result) if result is not None else ""
|
|
361
|
+
|
|
362
|
+
async def check_config(self) -> dict[str, Any]:
|
|
363
|
+
"""Check Home Assistant configuration."""
|
|
364
|
+
logger.debug("Checking configuration")
|
|
365
|
+
return await self._request("POST", "/config/core/check_config")
|
|
366
|
+
|
|
367
|
+
async def get_error_log(self) -> str:
|
|
368
|
+
"""Get Home Assistant error log."""
|
|
369
|
+
logger.debug("Fetching error log")
|
|
370
|
+
response = await self._request("GET", "/error_log")
|
|
371
|
+
return response if isinstance(response, str) else str(response)
|
|
372
|
+
|
|
373
|
+
async def test_connection(self) -> tuple[bool, str | None]:
|
|
374
|
+
"""
|
|
375
|
+
Test connection to Home Assistant.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
tuple: (success, error_message)
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
config = await self.get_config()
|
|
382
|
+
if config.get("location_name"):
|
|
383
|
+
logger.info(
|
|
384
|
+
f"Successfully connected to Home Assistant: {config['location_name']}"
|
|
385
|
+
)
|
|
386
|
+
return True, None
|
|
387
|
+
else:
|
|
388
|
+
return False, "Invalid response from Home Assistant"
|
|
389
|
+
except Exception as e:
|
|
390
|
+
logger.error(f"Failed to connect to Home Assistant: {e}")
|
|
391
|
+
return False, str(e)
|
|
392
|
+
|
|
393
|
+
async def get_system_health(self) -> dict[str, Any]:
|
|
394
|
+
"""Get system health information."""
|
|
395
|
+
logger.debug("Fetching system health")
|
|
396
|
+
try:
|
|
397
|
+
return await self._request("GET", "/system_health/info")
|
|
398
|
+
except HomeAssistantAPIError:
|
|
399
|
+
# System health might not be available in all HA instances
|
|
400
|
+
return {"status": "unknown", "message": "System health not available"}
|
|
401
|
+
|
|
402
|
+
# Automation Configuration Management
|
|
403
|
+
|
|
404
|
+
async def _resolve_automation_id(self, identifier: str) -> str:
|
|
405
|
+
"""
|
|
406
|
+
Convert entity_id to unique_id if needed, or return unique_id as-is.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
identifier: Either entity_id (automation.xxx) or unique_id
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
The unique_id for configuration API
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
HomeAssistantAPIError: If automation not found
|
|
416
|
+
"""
|
|
417
|
+
# If it looks like an entity_id, convert to unique_id
|
|
418
|
+
if identifier.startswith("automation."):
|
|
419
|
+
try:
|
|
420
|
+
state = await self.get_entity_state(identifier)
|
|
421
|
+
unique_id = state.get("attributes", {}).get("id")
|
|
422
|
+
if not unique_id:
|
|
423
|
+
raise HomeAssistantAPIError(
|
|
424
|
+
f"Automation {identifier} has no unique_id attribute",
|
|
425
|
+
status_code=404,
|
|
426
|
+
)
|
|
427
|
+
logger.debug(
|
|
428
|
+
f"Converted entity_id {identifier} to unique_id {unique_id}"
|
|
429
|
+
)
|
|
430
|
+
return str(unique_id)
|
|
431
|
+
except Exception as e:
|
|
432
|
+
raise HomeAssistantAPIError(
|
|
433
|
+
f"Failed to resolve automation {identifier}: {str(e)}",
|
|
434
|
+
status_code=404,
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
# Assume it's already a unique_id
|
|
438
|
+
return identifier
|
|
439
|
+
|
|
440
|
+
async def get_automation_config(self, identifier: str) -> dict[str, Any]:
|
|
441
|
+
"""
|
|
442
|
+
Get automation configuration by unique_id or entity_id.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
identifier: Either automation entity_id (automation.xxx) or unique_id
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Automation configuration dictionary
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
HomeAssistantAPIError: If automation not found or API error
|
|
452
|
+
"""
|
|
453
|
+
unique_id = await self._resolve_automation_id(identifier)
|
|
454
|
+
logger.debug(f"Fetching automation config for unique_id: {unique_id}")
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
response = await self._request(
|
|
458
|
+
"GET", f"/config/automation/config/{unique_id}"
|
|
459
|
+
)
|
|
460
|
+
return response
|
|
461
|
+
except Exception as e:
|
|
462
|
+
if "404" in str(e):
|
|
463
|
+
raise HomeAssistantAPIError(
|
|
464
|
+
f"Automation not found: {identifier} (unique_id: {unique_id})",
|
|
465
|
+
status_code=404,
|
|
466
|
+
)
|
|
467
|
+
raise
|
|
468
|
+
|
|
469
|
+
async def upsert_automation_config(
|
|
470
|
+
self, config: dict[str, Any], identifier: str | None = None
|
|
471
|
+
) -> dict[str, Any]:
|
|
472
|
+
"""
|
|
473
|
+
Create new automation or update existing one.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
config: Automation configuration dictionary
|
|
477
|
+
identifier: Optional automation entity_id or unique_id (None = create new)
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
Result with automation unique_id and status
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
HomeAssistantAPIError: If configuration invalid or API error
|
|
484
|
+
"""
|
|
485
|
+
import time
|
|
486
|
+
|
|
487
|
+
# Generate unique_id for new automation if not provided
|
|
488
|
+
if identifier is None:
|
|
489
|
+
unique_id = str(int(time.time() * 1000))
|
|
490
|
+
operation = "created"
|
|
491
|
+
logger.debug(f"Creating new automation with unique_id: {unique_id}")
|
|
492
|
+
else:
|
|
493
|
+
unique_id = await self._resolve_automation_id(identifier)
|
|
494
|
+
operation = "updated"
|
|
495
|
+
logger.debug(f"Updating automation with unique_id: {unique_id}")
|
|
496
|
+
|
|
497
|
+
# Add unique_id to config for updates
|
|
498
|
+
if unique_id and "id" not in config:
|
|
499
|
+
config = {**config, "id": unique_id}
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
response = await self._request(
|
|
503
|
+
"POST", f"/config/automation/config/{unique_id}", json=config
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# For new automations, query Home Assistant to get the actual entity_id that was assigned
|
|
507
|
+
actual_entity_id = None
|
|
508
|
+
if operation == "created":
|
|
509
|
+
try:
|
|
510
|
+
# Give Home Assistant a moment to register the entity
|
|
511
|
+
import asyncio
|
|
512
|
+
|
|
513
|
+
await asyncio.sleep(1)
|
|
514
|
+
|
|
515
|
+
# Get all automations and find the one with our unique_id
|
|
516
|
+
states = await self.get_states()
|
|
517
|
+
for state in states:
|
|
518
|
+
if state.get("entity_id", "").startswith("automation."):
|
|
519
|
+
attributes = state.get("attributes", {})
|
|
520
|
+
if attributes.get("id") == unique_id:
|
|
521
|
+
actual_entity_id = state.get("entity_id")
|
|
522
|
+
logger.debug(
|
|
523
|
+
f"Found actual entity_id for unique_id {unique_id}: {actual_entity_id}"
|
|
524
|
+
)
|
|
525
|
+
break
|
|
526
|
+
|
|
527
|
+
if not actual_entity_id:
|
|
528
|
+
# Fallback to predicted entity_id if we can't find it
|
|
529
|
+
actual_entity_id = f"automation.{config.get('alias', unique_id).lower().replace(' ', '_').replace('-', '_')}"
|
|
530
|
+
logger.warning(
|
|
531
|
+
f"Could not find actual entity_id for unique_id {unique_id}, using predicted: {actual_entity_id}"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
except Exception as e:
|
|
535
|
+
logger.warning(
|
|
536
|
+
f"Failed to query actual entity_id for unique_id {unique_id}: {e}"
|
|
537
|
+
)
|
|
538
|
+
# Fallback to predicted entity_id
|
|
539
|
+
actual_entity_id = f"automation.{config.get('alias', unique_id).lower().replace(' ', '_').replace('-', '_')}"
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
"unique_id": unique_id,
|
|
543
|
+
"entity_id": actual_entity_id,
|
|
544
|
+
"result": response.get("result", "ok"),
|
|
545
|
+
"operation": operation,
|
|
546
|
+
}
|
|
547
|
+
except Exception as e:
|
|
548
|
+
if "400" in str(e):
|
|
549
|
+
raise HomeAssistantAPIError(
|
|
550
|
+
f"Invalid automation configuration: {str(e)}", status_code=400
|
|
551
|
+
)
|
|
552
|
+
raise
|
|
553
|
+
|
|
554
|
+
async def delete_automation_config(self, identifier: str) -> dict[str, Any]:
|
|
555
|
+
"""
|
|
556
|
+
Delete automation configuration by entity_id or unique_id.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
identifier: Either automation entity_id (automation.xxx) or unique_id
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
Deletion result
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
HomeAssistantAPIError: If automation not found or API error
|
|
566
|
+
"""
|
|
567
|
+
unique_id = await self._resolve_automation_id(identifier)
|
|
568
|
+
logger.debug(f"Deleting automation config for unique_id: {unique_id}")
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
response = await self._request(
|
|
572
|
+
"DELETE", f"/config/automation/config/{unique_id}"
|
|
573
|
+
)
|
|
574
|
+
return {
|
|
575
|
+
"identifier": identifier,
|
|
576
|
+
"unique_id": unique_id,
|
|
577
|
+
"result": response.get("result", "ok"),
|
|
578
|
+
"operation": "deleted",
|
|
579
|
+
}
|
|
580
|
+
except HomeAssistantAPIError as e:
|
|
581
|
+
if e.status_code == 404:
|
|
582
|
+
raise HomeAssistantAPIError(
|
|
583
|
+
f"Automation not found: {identifier} (unique_id: {unique_id})",
|
|
584
|
+
status_code=404,
|
|
585
|
+
)
|
|
586
|
+
elif e.status_code == 405:
|
|
587
|
+
raise HomeAssistantAPIError(
|
|
588
|
+
f"Cannot delete automation '{identifier}': The HTTP DELETE method is blocked. "
|
|
589
|
+
f"This typically occurs when running ha-mcp as a Home Assistant add-on, because "
|
|
590
|
+
f"the Supervisor ingress proxy only allows GET and POST requests. "
|
|
591
|
+
f"WORKAROUNDS: "
|
|
592
|
+
f"(1) Use ha-mcp via pip, Docker, or as an external MCP server instead of the add-on. "
|
|
593
|
+
f"(2) Use a long-lived access token to connect directly to Home Assistant's API. "
|
|
594
|
+
f"(3) As a fallback, disable the automation and rename it with a 'DELETE_' prefix "
|
|
595
|
+
f"(e.g., 'DELETE_{identifier}') so you can identify and manually delete it later "
|
|
596
|
+
f"via the Home Assistant UI (Settings > Automations & Scenes).",
|
|
597
|
+
status_code=405,
|
|
598
|
+
)
|
|
599
|
+
raise
|
|
600
|
+
except Exception as e:
|
|
601
|
+
if "404" in str(e):
|
|
602
|
+
raise HomeAssistantAPIError(
|
|
603
|
+
f"Automation not found: {identifier} (unique_id: {unique_id})",
|
|
604
|
+
status_code=404,
|
|
605
|
+
)
|
|
606
|
+
raise
|
|
607
|
+
|
|
608
|
+
async def start_config_flow(
|
|
609
|
+
self, handler: str, context: dict[str, Any] | None = None
|
|
610
|
+
) -> dict[str, Any]:
|
|
611
|
+
"""
|
|
612
|
+
Start a config entry flow.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
handler: Integration domain (e.g., "template", "group")
|
|
616
|
+
context: Optional context (e.g., {"source": "user"})
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
Flow data with flow_id, step_id, data_schema
|
|
620
|
+
|
|
621
|
+
Raises:
|
|
622
|
+
HomeAssistantAPIError: If flow start fails
|
|
623
|
+
"""
|
|
624
|
+
payload = {"handler": handler}
|
|
625
|
+
if context:
|
|
626
|
+
payload["context"] = context
|
|
627
|
+
|
|
628
|
+
logger.debug(f"Starting config flow for handler: {handler}")
|
|
629
|
+
return await self._request("POST", "/config/config_entries/flow", json=payload)
|
|
630
|
+
|
|
631
|
+
async def submit_config_flow_step(
|
|
632
|
+
self, flow_id: str, user_input: dict[str, Any]
|
|
633
|
+
) -> dict[str, Any]:
|
|
634
|
+
"""
|
|
635
|
+
Submit data for a config flow step.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
flow_id: Flow ID from start_config_flow or previous step
|
|
639
|
+
user_input: Form data for current step
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
Flow result: type = "create_entry" | "form" | "abort"
|
|
643
|
+
|
|
644
|
+
Raises:
|
|
645
|
+
HomeAssistantAPIError: If flow submission fails
|
|
646
|
+
"""
|
|
647
|
+
logger.debug(f"Submitting flow step for flow_id: {flow_id}")
|
|
648
|
+
return await self._request(
|
|
649
|
+
"POST", f"/config/config_entries/flow/{flow_id}", json=user_input
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
async def get_config_entry(self, entry_id: str) -> dict[str, Any]:
|
|
653
|
+
"""
|
|
654
|
+
Get config entry details.
|
|
655
|
+
|
|
656
|
+
Note: Home Assistant doesn't have a direct REST API endpoint for individual
|
|
657
|
+
config entries. This method lists all entries and filters by entry_id.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
entry_id: Config entry ID
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
Full config entry data
|
|
664
|
+
|
|
665
|
+
Raises:
|
|
666
|
+
HomeAssistantAPIError: If entry not found or API error
|
|
667
|
+
"""
|
|
668
|
+
logger.debug(f"Getting config entry: {entry_id}")
|
|
669
|
+
# List all entries and filter by entry_id
|
|
670
|
+
entries = await self._request("GET", "/config/config_entries/entry")
|
|
671
|
+
|
|
672
|
+
if not isinstance(entries, list):
|
|
673
|
+
raise HomeAssistantAPIError(
|
|
674
|
+
"Unexpected response format from config entries API",
|
|
675
|
+
status_code=500,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
for entry in entries:
|
|
679
|
+
if entry.get("entry_id") == entry_id:
|
|
680
|
+
return entry
|
|
681
|
+
|
|
682
|
+
raise HomeAssistantAPIError(
|
|
683
|
+
f"Config entry not found: {entry_id}",
|
|
684
|
+
status_code=404,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
async def send_websocket_message(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
688
|
+
"""Send message via WebSocket and wait for response.
|
|
689
|
+
|
|
690
|
+
Uses the global WebSocket singleton to avoid race conditions from
|
|
691
|
+
parallel tool calls creating multiple simultaneous connections.
|
|
692
|
+
"""
|
|
693
|
+
from .websocket_client import get_websocket_client
|
|
694
|
+
|
|
695
|
+
max_retries = 2
|
|
696
|
+
retry_delay = 0.5 # seconds
|
|
697
|
+
|
|
698
|
+
for attempt in range(max_retries):
|
|
699
|
+
try:
|
|
700
|
+
# Use singleton WebSocket client (shared, reused connection)
|
|
701
|
+
ws_client = await get_websocket_client()
|
|
702
|
+
|
|
703
|
+
# Special handling for render_template which returns an event with the actual result
|
|
704
|
+
if message.get("type") == "render_template":
|
|
705
|
+
return await self._handle_render_template(ws_client, message)
|
|
706
|
+
|
|
707
|
+
# Extract command type and parameters for other commands
|
|
708
|
+
message_copy = message.copy()
|
|
709
|
+
command_type = message_copy.pop("type")
|
|
710
|
+
result = await ws_client.send_command(command_type, **message_copy)
|
|
711
|
+
|
|
712
|
+
return result
|
|
713
|
+
|
|
714
|
+
except Exception as e:
|
|
715
|
+
error_str = str(e)
|
|
716
|
+
|
|
717
|
+
# Detect transient 403 errors (rate limiting / reverse proxy throttling)
|
|
718
|
+
if "403" in error_str and "Forbidden" in error_str:
|
|
719
|
+
if attempt < max_retries - 1:
|
|
720
|
+
logger.warning(
|
|
721
|
+
f"WebSocket 403 error (attempt {attempt + 1}/{max_retries}), "
|
|
722
|
+
f"retrying after {retry_delay}s: {error_str}"
|
|
723
|
+
)
|
|
724
|
+
await asyncio.sleep(retry_delay)
|
|
725
|
+
continue
|
|
726
|
+
else:
|
|
727
|
+
logger.error(f"WebSocket 403 error after {max_retries} attempts: {error_str}")
|
|
728
|
+
return {
|
|
729
|
+
"success": False,
|
|
730
|
+
"error": f"WebSocket request blocked (403 Forbidden): {error_str}",
|
|
731
|
+
"suggestions": [
|
|
732
|
+
"This may be caused by a reverse proxy or security filter",
|
|
733
|
+
"Try simplifying the request (e.g., shorter templates, fewer parameters)",
|
|
734
|
+
"If using complex templates, try breaking them into smaller parts",
|
|
735
|
+
"Check if your Home Assistant is behind a reverse proxy with security rules",
|
|
736
|
+
],
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
logger.error(f"WebSocket message failed: {e}")
|
|
740
|
+
return {"success": False, "error": str(e)}
|
|
741
|
+
|
|
742
|
+
async def _handle_render_template(
|
|
743
|
+
self, ws_client: Any, message: dict[str, Any]
|
|
744
|
+
) -> dict[str, Any]:
|
|
745
|
+
"""Handle render_template WebSocket command with event-based response."""
|
|
746
|
+
|
|
747
|
+
# Generate our own message ID to track the response
|
|
748
|
+
message_id = ws_client.get_next_message_id()
|
|
749
|
+
|
|
750
|
+
# Construct the full message with proper ID
|
|
751
|
+
full_message = {
|
|
752
|
+
"id": message_id,
|
|
753
|
+
"type": "render_template",
|
|
754
|
+
"template": message.get("template"),
|
|
755
|
+
"timeout": message.get("timeout", 3),
|
|
756
|
+
"report_errors": message.get("report_errors", True),
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
# Create futures for both result and event responses
|
|
760
|
+
result_future = ws_client.register_pending_response(message_id)
|
|
761
|
+
event_future = ws_client.register_render_template_event(message_id)
|
|
762
|
+
|
|
763
|
+
# Use WebSocket client's send helper to transmit the message
|
|
764
|
+
try:
|
|
765
|
+
await ws_client.send_json_message(full_message)
|
|
766
|
+
except Exception as e:
|
|
767
|
+
ws_client.cancel_pending_response(message_id)
|
|
768
|
+
ws_client.cancel_render_template_event(message_id)
|
|
769
|
+
raise e
|
|
770
|
+
|
|
771
|
+
try:
|
|
772
|
+
# Wait for the initial result response (should be success with null result)
|
|
773
|
+
result_response = await asyncio.wait_for(
|
|
774
|
+
result_future, timeout=message.get("timeout", 3) + 2
|
|
775
|
+
)
|
|
776
|
+
logger.debug(f"WebSocket render_template result: {result_response}")
|
|
777
|
+
|
|
778
|
+
if not result_response.get("success"):
|
|
779
|
+
ws_client.cancel_render_template_event(message_id)
|
|
780
|
+
error = result_response.get("error", "Unknown error")
|
|
781
|
+
return {
|
|
782
|
+
"success": False,
|
|
783
|
+
"error": str(error),
|
|
784
|
+
"template": message.get("template"),
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
# Wait for the event with the actual template result
|
|
788
|
+
try:
|
|
789
|
+
event_response = await asyncio.wait_for(
|
|
790
|
+
event_future, timeout=message.get("timeout", 3) + 1
|
|
791
|
+
)
|
|
792
|
+
logger.debug(f"WebSocket render_template event: {event_response}")
|
|
793
|
+
|
|
794
|
+
# Extract template result from event
|
|
795
|
+
if "event" in event_response and "result" in event_response["event"]:
|
|
796
|
+
template_result = event_response["event"]["result"]
|
|
797
|
+
listeners_info = event_response["event"].get("listeners", {})
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
"success": True,
|
|
801
|
+
"result": template_result,
|
|
802
|
+
"template": message.get("template"),
|
|
803
|
+
"listeners": listeners_info,
|
|
804
|
+
}
|
|
805
|
+
else:
|
|
806
|
+
return {
|
|
807
|
+
"success": False,
|
|
808
|
+
"error": "Invalid event response format",
|
|
809
|
+
"template": message.get("template"),
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
except TimeoutError:
|
|
813
|
+
ws_client.cancel_render_template_event(message_id)
|
|
814
|
+
return {
|
|
815
|
+
"success": False,
|
|
816
|
+
"error": "Event timeout - template result not received",
|
|
817
|
+
"template": message.get("template"),
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
except TimeoutError:
|
|
821
|
+
ws_client.cancel_pending_response(message_id)
|
|
822
|
+
ws_client.cancel_render_template_event(message_id)
|
|
823
|
+
return {
|
|
824
|
+
"success": False,
|
|
825
|
+
"error": "Command timeout",
|
|
826
|
+
"template": message.get("template"),
|
|
827
|
+
}
|
|
828
|
+
except Exception as e:
|
|
829
|
+
ws_client.cancel_pending_response(message_id)
|
|
830
|
+
ws_client.cancel_render_template_event(message_id)
|
|
831
|
+
return {
|
|
832
|
+
"success": False,
|
|
833
|
+
"error": str(e),
|
|
834
|
+
"template": message.get("template"),
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async def get_script_config(self, script_id: str) -> dict[str, Any]:
|
|
838
|
+
"""Get Home Assistant script configuration by script_id."""
|
|
839
|
+
try:
|
|
840
|
+
endpoint = f"config/script/config/{script_id}"
|
|
841
|
+
response = await self._request("GET", endpoint)
|
|
842
|
+
|
|
843
|
+
return {"success": True, "script_id": script_id, "config": response}
|
|
844
|
+
except HomeAssistantAPIError as e:
|
|
845
|
+
if e.status_code == 404:
|
|
846
|
+
raise HomeAssistantAPIError(
|
|
847
|
+
f"Script not found: {script_id}", status_code=404
|
|
848
|
+
)
|
|
849
|
+
raise
|
|
850
|
+
except Exception as e:
|
|
851
|
+
logger.error(f"Failed to get script config for {script_id}: {e}")
|
|
852
|
+
raise
|
|
853
|
+
|
|
854
|
+
async def upsert_script_config(
|
|
855
|
+
self, config: dict[str, Any], script_id: str
|
|
856
|
+
) -> dict[str, Any]:
|
|
857
|
+
"""Create or update Home Assistant script configuration."""
|
|
858
|
+
try:
|
|
859
|
+
endpoint = f"config/script/config/{script_id}"
|
|
860
|
+
|
|
861
|
+
# Validate required fields
|
|
862
|
+
if "alias" not in config:
|
|
863
|
+
config["alias"] = script_id
|
|
864
|
+
if "sequence" not in config:
|
|
865
|
+
raise ValueError("Script configuration must include 'sequence'")
|
|
866
|
+
|
|
867
|
+
response = await self._request("POST", endpoint, json=config)
|
|
868
|
+
|
|
869
|
+
return {
|
|
870
|
+
"success": True,
|
|
871
|
+
"script_id": script_id,
|
|
872
|
+
"result": response.get("result", "ok"),
|
|
873
|
+
"operation": "created" if response.get("result") == "ok" else "updated",
|
|
874
|
+
}
|
|
875
|
+
except Exception as e:
|
|
876
|
+
logger.error(f"Failed to upsert script config for {script_id}: {e}")
|
|
877
|
+
raise
|
|
878
|
+
|
|
879
|
+
async def delete_script_config(self, script_id: str) -> dict[str, Any]:
|
|
880
|
+
"""Delete Home Assistant script configuration."""
|
|
881
|
+
try:
|
|
882
|
+
endpoint = f"config/script/config/{script_id}"
|
|
883
|
+
response = await self._request("DELETE", endpoint)
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
"success": True,
|
|
887
|
+
"script_id": script_id,
|
|
888
|
+
"result": response.get("result", "ok"),
|
|
889
|
+
"operation": "deleted",
|
|
890
|
+
}
|
|
891
|
+
except HomeAssistantAPIError as e:
|
|
892
|
+
if e.status_code == 404:
|
|
893
|
+
raise HomeAssistantAPIError(
|
|
894
|
+
f"Script not found: {script_id}", status_code=404
|
|
895
|
+
)
|
|
896
|
+
elif e.status_code == 405:
|
|
897
|
+
raise HomeAssistantAPIError(
|
|
898
|
+
f"Cannot delete script '{script_id}': The HTTP DELETE method is blocked. "
|
|
899
|
+
f"This typically occurs when running ha-mcp as a Home Assistant add-on, because "
|
|
900
|
+
f"the Supervisor ingress proxy only allows GET and POST requests. "
|
|
901
|
+
f"It may also occur if the script is defined in YAML configuration files. "
|
|
902
|
+
f"WORKAROUNDS: "
|
|
903
|
+
f"(1) Use ha-mcp via pip, Docker, or as an external MCP server instead of the add-on. "
|
|
904
|
+
f"(2) Use a long-lived access token to connect directly to Home Assistant's API. "
|
|
905
|
+
f"(3) If the script is YAML-defined, edit the configuration file directly. "
|
|
906
|
+
f"(4) As a fallback, disable the script and rename it with a 'DELETE_' prefix "
|
|
907
|
+
f"(e.g., 'DELETE_{script_id}') so you can identify and manually delete it later "
|
|
908
|
+
f"via the Home Assistant UI (Settings > Automations & Scenes > Scripts).",
|
|
909
|
+
status_code=405,
|
|
910
|
+
)
|
|
911
|
+
raise
|
|
912
|
+
except Exception as e:
|
|
913
|
+
raise
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
async def create_client() -> HomeAssistantClient:
|
|
917
|
+
"""Create and return a new Home Assistant client."""
|
|
918
|
+
return HomeAssistantClient()
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
async def test_connection_with_config() -> tuple[bool, str | None]:
|
|
922
|
+
"""Test connection using configuration settings."""
|
|
923
|
+
async with HomeAssistantClient() as client:
|
|
924
|
+
return await client.test_connection()
|