ha-mcp-dev 6.3.1.dev137__py3-none-any.whl

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