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,622 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dashboard resource hosting tools for Home Assistant MCP server.
|
|
3
|
+
|
|
4
|
+
Provides tools for managing dashboard resources (custom cards, CSS, JS):
|
|
5
|
+
- Inline resources: Code embedded in URL via Cloudflare Worker
|
|
6
|
+
- External resources: URLs to /local/, /hacsfiles/, or external CDNs
|
|
7
|
+
|
|
8
|
+
See: https://github.com/homeassistant-ai/ha-mcp/issues/266
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Annotated, Any, Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
from .helpers import log_tool_usage
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Cloudflare Worker URL for resource hosting
|
|
22
|
+
WORKER_BASE_URL = "https://ha-mcp-resources.rapid-math-bbad.workers.dev"
|
|
23
|
+
|
|
24
|
+
# Maximum base64-encoded URL path length (tested limit: 32KB)
|
|
25
|
+
MAX_ENCODED_LENGTH = 32000
|
|
26
|
+
|
|
27
|
+
# Maximum content size (~24KB before base64 encoding)
|
|
28
|
+
# Base64 encoding increases size by ~33%, so 24KB * 1.33 ≈ 32KB
|
|
29
|
+
MAX_CONTENT_SIZE = 24000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _encode_content(content: str) -> tuple[str, int, int]:
|
|
33
|
+
"""Encode content to URL-safe base64. Returns (encoded, content_size, encoded_size)."""
|
|
34
|
+
content_bytes = content.encode("utf-8")
|
|
35
|
+
encoded = base64.urlsafe_b64encode(content_bytes).decode("ascii")
|
|
36
|
+
return encoded, len(content_bytes), len(encoded)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _decode_inline_url(url: str) -> str | None:
|
|
40
|
+
"""Decode an inline resource URL back to content. Returns None if not an inline URL."""
|
|
41
|
+
if WORKER_BASE_URL not in url:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
# Extract base64 part: https://worker.dev/{base64}?type=module
|
|
45
|
+
encoded = url.replace(f"{WORKER_BASE_URL}/", "").split("?")[0]
|
|
46
|
+
return base64.urlsafe_b64decode(encoded).decode("utf-8")
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_inline_url(url: str) -> bool:
|
|
52
|
+
"""Check if a URL is an inline resource URL."""
|
|
53
|
+
return WORKER_BASE_URL in url
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register_resources_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
|
|
57
|
+
"""Register dashboard resource tools."""
|
|
58
|
+
|
|
59
|
+
# =========================================================================
|
|
60
|
+
# List Dashboard Resources
|
|
61
|
+
# =========================================================================
|
|
62
|
+
|
|
63
|
+
@mcp.tool(
|
|
64
|
+
annotations={
|
|
65
|
+
"idempotentHint": True,
|
|
66
|
+
"readOnlyHint": True,
|
|
67
|
+
"tags": ["dashboard", "resources"],
|
|
68
|
+
"title": "List Dashboard Resources",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
@log_tool_usage
|
|
72
|
+
async def ha_config_list_dashboard_resources(
|
|
73
|
+
include_content: Annotated[
|
|
74
|
+
bool,
|
|
75
|
+
Field(
|
|
76
|
+
description="Include full decoded content for inline resources. "
|
|
77
|
+
"Default False to save tokens (shows 150-char preview instead)."
|
|
78
|
+
),
|
|
79
|
+
] = False,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""
|
|
82
|
+
List all Lovelace dashboard resources (custom cards, themes, CSS/JS).
|
|
83
|
+
|
|
84
|
+
Returns all registered resources. For inline resources (created with
|
|
85
|
+
ha_config_set_inline_dashboard_resource), shows a preview of the content
|
|
86
|
+
instead of the full encoded URL to save tokens.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
include_content: If True, includes full decoded content for inline
|
|
90
|
+
resources in "_content" field. Default False (150-char preview only).
|
|
91
|
+
|
|
92
|
+
Resource types:
|
|
93
|
+
- module: ES6 JavaScript modules (modern custom cards)
|
|
94
|
+
- js: Legacy JavaScript files
|
|
95
|
+
- css: CSS stylesheets
|
|
96
|
+
|
|
97
|
+
Each resource has a unique ID for update/delete operations.
|
|
98
|
+
|
|
99
|
+
EXAMPLES:
|
|
100
|
+
- List all resources: ha_config_list_dashboard_resources()
|
|
101
|
+
- List with full content: ha_config_list_dashboard_resources(include_content=True)
|
|
102
|
+
|
|
103
|
+
Note: Requires advanced mode to be enabled in Home Assistant for resource
|
|
104
|
+
management through the UI, but API access works regardless.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
result = await client.send_websocket_message({"type": "lovelace/resources"})
|
|
108
|
+
|
|
109
|
+
# Handle WebSocket response format
|
|
110
|
+
if isinstance(result, dict) and "result" in result:
|
|
111
|
+
resources = result["result"]
|
|
112
|
+
elif isinstance(result, list):
|
|
113
|
+
resources = result
|
|
114
|
+
else:
|
|
115
|
+
resources = []
|
|
116
|
+
|
|
117
|
+
# Process resources - decode inline URLs for preview
|
|
118
|
+
processed = []
|
|
119
|
+
for resource in resources:
|
|
120
|
+
res = dict(resource)
|
|
121
|
+
url = res.get("url", "")
|
|
122
|
+
|
|
123
|
+
if _is_inline_url(url):
|
|
124
|
+
# Decode inline content
|
|
125
|
+
content = _decode_inline_url(url)
|
|
126
|
+
if content:
|
|
127
|
+
res["_inline"] = True
|
|
128
|
+
res["_size"] = len(content)
|
|
129
|
+
|
|
130
|
+
if include_content:
|
|
131
|
+
# Include full content when requested
|
|
132
|
+
res["_content"] = content
|
|
133
|
+
else:
|
|
134
|
+
# Show preview (first 150 chars) to save tokens
|
|
135
|
+
preview = content[:150]
|
|
136
|
+
if len(content) > 150:
|
|
137
|
+
preview += "..."
|
|
138
|
+
res["_preview"] = preview
|
|
139
|
+
|
|
140
|
+
# Replace URL with placeholder to save tokens
|
|
141
|
+
res["url"] = "[inline]"
|
|
142
|
+
|
|
143
|
+
processed.append(res)
|
|
144
|
+
|
|
145
|
+
# Categorize resources by type
|
|
146
|
+
categorized: dict[str, list[Any]] = {"module": [], "js": [], "css": []}
|
|
147
|
+
inline_count = 0
|
|
148
|
+
for res in processed:
|
|
149
|
+
res_type = res.get("type", "unknown")
|
|
150
|
+
if res_type in categorized:
|
|
151
|
+
categorized[res_type].append(res)
|
|
152
|
+
if res.get("_inline"):
|
|
153
|
+
inline_count += 1
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"success": True,
|
|
157
|
+
"action": "list",
|
|
158
|
+
"resources": processed,
|
|
159
|
+
"count": len(processed),
|
|
160
|
+
"inline_count": inline_count,
|
|
161
|
+
"by_type": {
|
|
162
|
+
"module": len(categorized["module"]),
|
|
163
|
+
"js": len(categorized["js"]),
|
|
164
|
+
"css": len(categorized["css"]),
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Error listing dashboard resources: {e}")
|
|
169
|
+
return {
|
|
170
|
+
"success": False,
|
|
171
|
+
"action": "list",
|
|
172
|
+
"error": str(e),
|
|
173
|
+
"suggestions": [
|
|
174
|
+
"Ensure Home Assistant is running and accessible",
|
|
175
|
+
"Check that you have admin permissions",
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# =========================================================================
|
|
180
|
+
# Set Inline Dashboard Resource (upsert)
|
|
181
|
+
# =========================================================================
|
|
182
|
+
|
|
183
|
+
@mcp.tool(
|
|
184
|
+
annotations={
|
|
185
|
+
"destructiveHint": True,
|
|
186
|
+
"tags": ["dashboard", "resources"],
|
|
187
|
+
"title": "Set Inline Dashboard Resource",
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
@log_tool_usage
|
|
191
|
+
async def ha_config_set_inline_dashboard_resource(
|
|
192
|
+
content: Annotated[
|
|
193
|
+
str,
|
|
194
|
+
Field(description="JavaScript or CSS code to host (max ~24KB)"),
|
|
195
|
+
],
|
|
196
|
+
resource_type: Annotated[
|
|
197
|
+
Literal["module", "css"],
|
|
198
|
+
Field(
|
|
199
|
+
description="Resource type: 'module' for ES6 JavaScript (custom cards), "
|
|
200
|
+
"'css' for stylesheets"
|
|
201
|
+
),
|
|
202
|
+
] = "module",
|
|
203
|
+
resource_id: Annotated[
|
|
204
|
+
str | None,
|
|
205
|
+
Field(
|
|
206
|
+
description="Resource ID to update. If omitted, creates a new resource. "
|
|
207
|
+
"Get IDs from ha_config_list_dashboard_resources()"
|
|
208
|
+
),
|
|
209
|
+
] = None,
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
"""
|
|
212
|
+
Create or update an inline dashboard resource from code.
|
|
213
|
+
|
|
214
|
+
Converts inline JavaScript or CSS into a hosted URL and registers it
|
|
215
|
+
in Home Assistant. The code is embedded in the URL (via Cloudflare Worker)
|
|
216
|
+
so no file storage is needed.
|
|
217
|
+
|
|
218
|
+
WHEN TO USE:
|
|
219
|
+
- Custom card code you've written inline
|
|
220
|
+
- CSS styling for dashboards
|
|
221
|
+
- Small utility modules (<24KB)
|
|
222
|
+
|
|
223
|
+
For larger files or external cards, use ha_config_set_dashboard_resource
|
|
224
|
+
with a URL to /local/, /hacsfiles/, or external CDN.
|
|
225
|
+
|
|
226
|
+
EXAMPLES:
|
|
227
|
+
|
|
228
|
+
Create a custom card:
|
|
229
|
+
ha_config_set_inline_dashboard_resource(
|
|
230
|
+
content=\"\"\"
|
|
231
|
+
class MyCard extends HTMLElement {
|
|
232
|
+
setConfig(config) { this.config = config; }
|
|
233
|
+
set hass(hass) {
|
|
234
|
+
this.innerHTML = `<ha-card>Hello ${hass.states[this.config.entity]?.state}</ha-card>`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
customElements.define('my-card', MyCard);
|
|
238
|
+
\"\"\",
|
|
239
|
+
resource_type="module"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
Update existing resource:
|
|
243
|
+
ha_config_set_inline_dashboard_resource(
|
|
244
|
+
content="/* updated CSS */",
|
|
245
|
+
resource_type="css",
|
|
246
|
+
resource_id="abc123"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
Notes:
|
|
250
|
+
- URLs are deterministic (same content = same URL)
|
|
251
|
+
- Content is decoded on-the-fly, not stored
|
|
252
|
+
- Max size: ~24KB source code
|
|
253
|
+
- Use ha_get_dashboard_guide for custom card patterns
|
|
254
|
+
"""
|
|
255
|
+
# Validate content
|
|
256
|
+
if not content or not content.strip():
|
|
257
|
+
return {
|
|
258
|
+
"success": False,
|
|
259
|
+
"error": "Content cannot be empty",
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
content_bytes = content.encode("utf-8")
|
|
263
|
+
content_size = len(content_bytes)
|
|
264
|
+
|
|
265
|
+
# Check size limit
|
|
266
|
+
if content_size > MAX_CONTENT_SIZE:
|
|
267
|
+
return {
|
|
268
|
+
"success": False,
|
|
269
|
+
"error": f"Content too large: {content_size:,} bytes (max {MAX_CONTENT_SIZE:,})",
|
|
270
|
+
"size": content_size,
|
|
271
|
+
"suggestions": [
|
|
272
|
+
"Minify the code to reduce size",
|
|
273
|
+
"Split into multiple smaller modules",
|
|
274
|
+
"Use ha_config_set_dashboard_resource with /local/ URL for larger files",
|
|
275
|
+
],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# Encode content
|
|
279
|
+
encoded, _, encoded_size = _encode_content(content)
|
|
280
|
+
|
|
281
|
+
if encoded_size > MAX_ENCODED_LENGTH:
|
|
282
|
+
return {
|
|
283
|
+
"success": False,
|
|
284
|
+
"error": f"Encoded content too large: {encoded_size:,} chars (max {MAX_ENCODED_LENGTH:,})",
|
|
285
|
+
"size": content_size,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
url = f"{WORKER_BASE_URL}/{encoded}?type={resource_type}"
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
if resource_id:
|
|
292
|
+
# Update existing resource
|
|
293
|
+
result = await client.send_websocket_message(
|
|
294
|
+
{
|
|
295
|
+
"type": "lovelace/resources/update",
|
|
296
|
+
"resource_id": resource_id,
|
|
297
|
+
"url": url,
|
|
298
|
+
"res_type": resource_type,
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
action = "updated"
|
|
302
|
+
else:
|
|
303
|
+
# Create new resource
|
|
304
|
+
result = await client.send_websocket_message(
|
|
305
|
+
{
|
|
306
|
+
"type": "lovelace/resources/create",
|
|
307
|
+
"url": url,
|
|
308
|
+
"res_type": resource_type,
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
action = "created"
|
|
312
|
+
|
|
313
|
+
# Check for errors
|
|
314
|
+
if isinstance(result, dict) and not result.get("success", True):
|
|
315
|
+
error_msg = result.get("error", {})
|
|
316
|
+
if isinstance(error_msg, dict):
|
|
317
|
+
error_msg = error_msg.get("message", str(error_msg))
|
|
318
|
+
return {
|
|
319
|
+
"success": False,
|
|
320
|
+
"action": action,
|
|
321
|
+
"error": str(error_msg),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# Extract resource ID from response
|
|
325
|
+
resource_info = result.get("result") if isinstance(result, dict) else result
|
|
326
|
+
new_resource_id = resource_id
|
|
327
|
+
if isinstance(resource_info, dict):
|
|
328
|
+
new_resource_id = resource_info.get("id", resource_id)
|
|
329
|
+
|
|
330
|
+
logger.info(
|
|
331
|
+
f"Inline dashboard resource {action}: id={new_resource_id}, "
|
|
332
|
+
f"type={resource_type}, size={content_size}"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
"success": True,
|
|
337
|
+
"action": action,
|
|
338
|
+
"resource_id": new_resource_id,
|
|
339
|
+
"resource_type": resource_type,
|
|
340
|
+
"size": content_size,
|
|
341
|
+
"note": "Clear browser cache or hard refresh to load changes",
|
|
342
|
+
}
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.error(f"Error setting inline dashboard resource: {e}")
|
|
345
|
+
return {
|
|
346
|
+
"success": False,
|
|
347
|
+
"action": "update" if resource_id else "create",
|
|
348
|
+
"error": str(e),
|
|
349
|
+
"suggestions": [
|
|
350
|
+
"Ensure Home Assistant is running and accessible",
|
|
351
|
+
"Check that you have admin permissions",
|
|
352
|
+
],
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# =========================================================================
|
|
356
|
+
# Set Dashboard Resource (upsert for external URLs)
|
|
357
|
+
# =========================================================================
|
|
358
|
+
|
|
359
|
+
@mcp.tool(
|
|
360
|
+
annotations={
|
|
361
|
+
"destructiveHint": True,
|
|
362
|
+
"tags": ["dashboard", "resources"],
|
|
363
|
+
"title": "Set Dashboard Resource",
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
@log_tool_usage
|
|
367
|
+
async def ha_config_set_dashboard_resource(
|
|
368
|
+
url: Annotated[
|
|
369
|
+
str,
|
|
370
|
+
Field(
|
|
371
|
+
description="URL of the resource. Can be: "
|
|
372
|
+
"/local/file.js (www/ directory), "
|
|
373
|
+
"/hacsfiles/component/file.js (HACS), "
|
|
374
|
+
"https://cdn.example.com/card.js (external)"
|
|
375
|
+
),
|
|
376
|
+
],
|
|
377
|
+
resource_type: Annotated[
|
|
378
|
+
Literal["module", "js", "css"],
|
|
379
|
+
Field(
|
|
380
|
+
description="Resource type: 'module' for ES6 modules (modern cards), "
|
|
381
|
+
"'js' for legacy JavaScript, 'css' for stylesheets"
|
|
382
|
+
),
|
|
383
|
+
] = "module",
|
|
384
|
+
resource_id: Annotated[
|
|
385
|
+
str | None,
|
|
386
|
+
Field(
|
|
387
|
+
description="Resource ID to update. If omitted, creates a new resource. "
|
|
388
|
+
"Get IDs from ha_config_list_dashboard_resources()"
|
|
389
|
+
),
|
|
390
|
+
] = None,
|
|
391
|
+
) -> dict[str, Any]:
|
|
392
|
+
"""
|
|
393
|
+
Create or update a dashboard resource from a URL.
|
|
394
|
+
|
|
395
|
+
Registers an external resource URL in Home Assistant. Use this for:
|
|
396
|
+
- Files in /config/www/ directory (/local/...)
|
|
397
|
+
- HACS-installed cards (/hacsfiles/...)
|
|
398
|
+
- External CDN resources (https://...)
|
|
399
|
+
|
|
400
|
+
For inline code, use ha_config_set_inline_dashboard_resource instead.
|
|
401
|
+
|
|
402
|
+
RESOURCE TYPES:
|
|
403
|
+
- module: ES6 JavaScript modules (recommended for custom cards)
|
|
404
|
+
- js: Legacy JavaScript files (older custom cards)
|
|
405
|
+
- css: CSS stylesheets (themes, global styles)
|
|
406
|
+
|
|
407
|
+
EXAMPLES:
|
|
408
|
+
|
|
409
|
+
Add custom card from www/ directory:
|
|
410
|
+
ha_config_set_dashboard_resource(
|
|
411
|
+
url="/local/my-custom-card.js",
|
|
412
|
+
resource_type="module"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
Add HACS card (after installing via ha_hacs_download):
|
|
416
|
+
ha_config_set_dashboard_resource(
|
|
417
|
+
url="/hacsfiles/lovelace-mushroom/mushroom.js",
|
|
418
|
+
resource_type="module"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
Update to new version:
|
|
422
|
+
ha_config_set_dashboard_resource(
|
|
423
|
+
url="/local/my-card-v2.js",
|
|
424
|
+
resource_type="module",
|
|
425
|
+
resource_id="abc123"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
Note: After adding a resource, clear browser cache or hard refresh
|
|
429
|
+
(Ctrl+Shift+R) to load changes.
|
|
430
|
+
"""
|
|
431
|
+
# Validate resource type
|
|
432
|
+
valid_types = ["module", "js", "css"]
|
|
433
|
+
if resource_type not in valid_types:
|
|
434
|
+
return {
|
|
435
|
+
"success": False,
|
|
436
|
+
"error": f"Invalid resource type '{resource_type}'",
|
|
437
|
+
"suggestions": [f"Valid types are: {', '.join(valid_types)}"],
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
if resource_id:
|
|
442
|
+
# Update existing resource
|
|
443
|
+
result = await client.send_websocket_message(
|
|
444
|
+
{
|
|
445
|
+
"type": "lovelace/resources/update",
|
|
446
|
+
"resource_id": resource_id,
|
|
447
|
+
"url": url,
|
|
448
|
+
"res_type": resource_type,
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
action = "updated"
|
|
452
|
+
else:
|
|
453
|
+
# Create new resource
|
|
454
|
+
result = await client.send_websocket_message(
|
|
455
|
+
{
|
|
456
|
+
"type": "lovelace/resources/create",
|
|
457
|
+
"url": url,
|
|
458
|
+
"res_type": resource_type,
|
|
459
|
+
}
|
|
460
|
+
)
|
|
461
|
+
action = "created"
|
|
462
|
+
|
|
463
|
+
# Check for errors
|
|
464
|
+
if isinstance(result, dict) and not result.get("success", True):
|
|
465
|
+
error_msg = result.get("error", {})
|
|
466
|
+
if isinstance(error_msg, dict):
|
|
467
|
+
error_msg = error_msg.get("message", str(error_msg))
|
|
468
|
+
|
|
469
|
+
# Check for duplicate error on create
|
|
470
|
+
error_str = str(error_msg).lower()
|
|
471
|
+
if "already exists" in error_str or "duplicate" in error_str:
|
|
472
|
+
return {
|
|
473
|
+
"success": False,
|
|
474
|
+
"action": action,
|
|
475
|
+
"url": url,
|
|
476
|
+
"error": "Resource with this URL already exists",
|
|
477
|
+
"suggestions": [
|
|
478
|
+
"Use ha_config_list_dashboard_resources() to find existing resource",
|
|
479
|
+
"Provide resource_id to update the existing resource",
|
|
480
|
+
],
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
"success": False,
|
|
485
|
+
"action": action,
|
|
486
|
+
"url": url,
|
|
487
|
+
"error": str(error_msg),
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
# Extract resource ID from response
|
|
491
|
+
resource_info = result.get("result") if isinstance(result, dict) else result
|
|
492
|
+
new_resource_id = resource_id
|
|
493
|
+
if isinstance(resource_info, dict):
|
|
494
|
+
new_resource_id = resource_info.get("id", resource_id)
|
|
495
|
+
|
|
496
|
+
logger.info(
|
|
497
|
+
f"Dashboard resource {action}: id={new_resource_id}, "
|
|
498
|
+
f"type={resource_type}, url={url}"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
"success": True,
|
|
503
|
+
"action": action,
|
|
504
|
+
"resource_id": new_resource_id,
|
|
505
|
+
"resource_type": resource_type,
|
|
506
|
+
"url": url,
|
|
507
|
+
"note": "Clear browser cache or hard refresh to load changes",
|
|
508
|
+
}
|
|
509
|
+
except Exception as e:
|
|
510
|
+
logger.error(f"Error setting dashboard resource: {e}")
|
|
511
|
+
return {
|
|
512
|
+
"success": False,
|
|
513
|
+
"action": "update" if resource_id else "create",
|
|
514
|
+
"url": url,
|
|
515
|
+
"error": str(e),
|
|
516
|
+
"suggestions": [
|
|
517
|
+
"Ensure Home Assistant is running and accessible",
|
|
518
|
+
"Check that you have admin permissions",
|
|
519
|
+
"Verify the URL is correctly formatted",
|
|
520
|
+
],
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
# =========================================================================
|
|
524
|
+
# Delete Dashboard Resource
|
|
525
|
+
# =========================================================================
|
|
526
|
+
|
|
527
|
+
@mcp.tool(
|
|
528
|
+
annotations={
|
|
529
|
+
"destructiveHint": True,
|
|
530
|
+
"idempotentHint": True,
|
|
531
|
+
"tags": ["dashboard", "resources"],
|
|
532
|
+
"title": "Delete Dashboard Resource",
|
|
533
|
+
}
|
|
534
|
+
)
|
|
535
|
+
@log_tool_usage
|
|
536
|
+
async def ha_config_delete_dashboard_resource(
|
|
537
|
+
resource_id: Annotated[
|
|
538
|
+
str,
|
|
539
|
+
Field(
|
|
540
|
+
description="Resource ID to delete. Get from ha_config_list_dashboard_resources()"
|
|
541
|
+
),
|
|
542
|
+
],
|
|
543
|
+
) -> dict[str, Any]:
|
|
544
|
+
"""
|
|
545
|
+
Delete a dashboard resource.
|
|
546
|
+
|
|
547
|
+
Removes a resource from Home Assistant. The resource will no longer
|
|
548
|
+
be loaded on dashboards. This operation is idempotent - deleting
|
|
549
|
+
a non-existent resource will succeed.
|
|
550
|
+
|
|
551
|
+
WARNING: Deleting a resource used by custom cards in your dashboards
|
|
552
|
+
will cause those cards to fail to load.
|
|
553
|
+
|
|
554
|
+
EXAMPLES:
|
|
555
|
+
ha_config_delete_dashboard_resource(resource_id="abc123")
|
|
556
|
+
|
|
557
|
+
Note: Use ha_config_list_dashboard_resources() to find resource IDs
|
|
558
|
+
before deleting. Ensure no dashboards depend on the resource.
|
|
559
|
+
"""
|
|
560
|
+
try:
|
|
561
|
+
result = await client.send_websocket_message(
|
|
562
|
+
{
|
|
563
|
+
"type": "lovelace/resources/delete",
|
|
564
|
+
"resource_id": resource_id,
|
|
565
|
+
}
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Check for errors
|
|
569
|
+
if isinstance(result, dict) and not result.get("success", True):
|
|
570
|
+
error_msg = result.get("error", {})
|
|
571
|
+
if isinstance(error_msg, dict):
|
|
572
|
+
error_str = error_msg.get("message", str(error_msg))
|
|
573
|
+
else:
|
|
574
|
+
error_str = str(error_msg)
|
|
575
|
+
|
|
576
|
+
# If "not found", treat as success (idempotent)
|
|
577
|
+
if "not found" in error_str.lower() or "unable to find" in error_str.lower():
|
|
578
|
+
return {
|
|
579
|
+
"success": True,
|
|
580
|
+
"action": "delete",
|
|
581
|
+
"resource_id": resource_id,
|
|
582
|
+
"message": "Resource already deleted or does not exist",
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
"success": False,
|
|
587
|
+
"action": "delete",
|
|
588
|
+
"resource_id": resource_id,
|
|
589
|
+
"error": error_str,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
logger.info(f"Dashboard resource deleted: id={resource_id}")
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
"success": True,
|
|
596
|
+
"action": "delete",
|
|
597
|
+
"resource_id": resource_id,
|
|
598
|
+
"message": "Resource deleted successfully",
|
|
599
|
+
}
|
|
600
|
+
except Exception as e:
|
|
601
|
+
error_str = str(e)
|
|
602
|
+
logger.error(f"Error deleting dashboard resource: {error_str}")
|
|
603
|
+
|
|
604
|
+
# If "not found", treat as success (idempotent)
|
|
605
|
+
if "not found" in error_str.lower() or "unable to find" in error_str.lower():
|
|
606
|
+
return {
|
|
607
|
+
"success": True,
|
|
608
|
+
"action": "delete",
|
|
609
|
+
"resource_id": resource_id,
|
|
610
|
+
"message": "Resource already deleted or does not exist",
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
"success": False,
|
|
615
|
+
"action": "delete",
|
|
616
|
+
"resource_id": resource_id,
|
|
617
|
+
"error": error_str,
|
|
618
|
+
"suggestions": [
|
|
619
|
+
"Verify resource ID using ha_config_list_dashboard_resources()",
|
|
620
|
+
"Check that you have admin permissions",
|
|
621
|
+
],
|
|
622
|
+
}
|