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,1502 @@
1
+ """
2
+ Configuration management tools for Home Assistant Lovelace dashboards.
3
+
4
+ This module provides tools for managing dashboard metadata and content.
5
+ """
6
+
7
+ import asyncio
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Annotated, Any, cast
14
+
15
+ import httpx
16
+ from pydantic import Field
17
+
18
+ from ..config import get_global_settings
19
+ from .helpers import log_tool_usage
20
+ from .util_helpers import parse_json_param
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Try to import jq - it's not available on Windows ARM64
25
+ try:
26
+ import jq
27
+ JQ_AVAILABLE = True
28
+ except ImportError:
29
+ JQ_AVAILABLE = False
30
+ logger.warning(
31
+ "jq library not available - jq_transform features will be disabled. "
32
+ "This is expected on Windows ARM64 where jq cannot be compiled."
33
+ )
34
+
35
+ # Error message when jq_transform is used without jq available
36
+ _JQ_UNAVAILABLE_ERROR = (
37
+ "jq_transform is not available - jq library could not be imported. "
38
+ "This is a known limitation on Windows ARM64 where jq cannot be compiled. "
39
+ "Please use the 'config' parameter for full config replacement instead, "
40
+ "or use ha-mcp on Windows x64, Linux, or macOS where jq is supported."
41
+ )
42
+
43
+ # Card documentation base URL
44
+ CARD_DOCS_BASE_URL = (
45
+ "https://raw.githubusercontent.com/home-assistant/home-assistant.io/"
46
+ "refs/heads/current/source/_dashboards"
47
+ )
48
+
49
+
50
+ def _get_resources_dir() -> Path:
51
+ """Get resources directory path, works for both dev and installed package."""
52
+ # Try to find resources directory relative to this file
53
+ resources_dir = Path(__file__).parent.parent / "resources"
54
+ if resources_dir.exists():
55
+ return resources_dir
56
+
57
+ # Fallback: try to find in package data (for installed packages)
58
+ try:
59
+ import importlib.resources as pkg_resources
60
+
61
+ # For Python 3.9+
62
+ if hasattr(pkg_resources, "files"):
63
+ resources_dir = pkg_resources.files("ha_mcp") / "resources"
64
+ if hasattr(resources_dir, "__fspath__"):
65
+ return Path(str(resources_dir))
66
+ except (ImportError, AttributeError):
67
+ # If importlib.resources or its attributes are unavailable, fall back to relative path
68
+ pass
69
+
70
+ # Last resort: return the relative path and let it fail with clear error
71
+ return resources_dir
72
+
73
+
74
+ def _compute_config_hash(config: dict[str, Any]) -> str:
75
+ """Compute a stable hash of dashboard config for optimistic locking."""
76
+ # Use sorted keys for deterministic serialization
77
+ config_str = json.dumps(config, sort_keys=True, separators=(",", ":"))
78
+ return hashlib.sha256(config_str.encode()).hexdigest()[:16]
79
+
80
+
81
+ async def _verify_config_unchanged(
82
+ client: Any,
83
+ url_path: str,
84
+ original_hash: str,
85
+ ) -> dict[str, Any]:
86
+ """
87
+ Verify dashboard config hasn't changed since original read.
88
+
89
+ Returns dict with:
90
+ - success: bool (True if config unchanged)
91
+ - error: str (if config changed)
92
+ - suggestions: list[str] (if config changed)
93
+ """
94
+ # Re-fetch current config
95
+ get_data: dict[str, Any] = {"type": "lovelace/config"}
96
+ if url_path:
97
+ get_data["url_path"] = url_path
98
+
99
+ result = await client.send_websocket_message(get_data)
100
+ current_config = result.get("result", result) if isinstance(result, dict) else result
101
+
102
+ if not isinstance(current_config, dict):
103
+ return {"success": True} # Can't verify, proceed anyway
104
+
105
+ current_hash = _compute_config_hash(current_config)
106
+
107
+ if current_hash != original_hash:
108
+ return {
109
+ "success": False,
110
+ "error": "Dashboard modified since last read (conflict)",
111
+ "suggestions": [
112
+ "Re-read dashboard with ha_config_get_dashboard",
113
+ "Then retry the operation with fresh data",
114
+ ],
115
+ }
116
+
117
+ return {"success": True}
118
+
119
+
120
+ def _apply_jq_transform(
121
+ config: dict[str, Any], expression: str
122
+ ) -> tuple[dict[str, Any] | None, str | None]:
123
+ """
124
+ Apply a jq transformation to dashboard config.
125
+
126
+ Returns:
127
+ tuple: (transformed_config, error_message)
128
+ - On success: (dict, None)
129
+ - On failure: (None, error_string)
130
+ """
131
+ # Check if jq is available
132
+ if not JQ_AVAILABLE:
133
+ return None, _JQ_UNAVAILABLE_ERROR
134
+
135
+ import jq
136
+
137
+ try:
138
+ # Compile and validate the jq expression
139
+ program = jq.compile(expression)
140
+ except ValueError as e:
141
+ return None, f"Invalid jq expression: {e}"
142
+
143
+ try:
144
+ # Execute the transformation
145
+ result = program.input_value(config).first()
146
+ except StopIteration:
147
+ return None, "jq expression produced no output"
148
+ except Exception as e:
149
+ return None, f"jq transformation error: {e}"
150
+
151
+ # Validate result is still a valid dashboard structure
152
+ if not isinstance(result, dict):
153
+ return None, f"jq result must be a dict, got {type(result).__name__}"
154
+
155
+ if "views" not in result and "strategy" not in result:
156
+ return None, "jq result missing required 'views' or 'strategy' key"
157
+
158
+ return result, None
159
+
160
+
161
+ def _find_cards_in_config(
162
+ config: dict[str, Any],
163
+ entity_id: str | None = None,
164
+ card_type: str | None = None,
165
+ heading: str | None = None,
166
+ ) -> list[dict[str, Any]]:
167
+ """
168
+ Find cards in a dashboard config matching the search criteria.
169
+
170
+ Returns a list of matches with location info and card config.
171
+ """
172
+ matches: list[dict[str, Any]] = []
173
+
174
+ if "strategy" in config:
175
+ return [] # Strategy dashboards don't have explicit cards
176
+
177
+ views = config.get("views", [])
178
+ for view_idx, view in enumerate(views):
179
+ if not isinstance(view, dict):
180
+ continue
181
+
182
+ view_type = view.get("type", "masonry")
183
+
184
+ if view_type == "sections":
185
+ # Sections-based view
186
+ sections = view.get("sections", [])
187
+ for section_idx, section in enumerate(sections):
188
+ if not isinstance(section, dict):
189
+ continue
190
+ cards = section.get("cards", [])
191
+ for card_idx, card in enumerate(cards):
192
+ if not isinstance(card, dict):
193
+ continue
194
+ if _card_matches(card, entity_id, card_type, heading):
195
+ matches.append({
196
+ "view_index": view_idx,
197
+ "section_index": section_idx,
198
+ "card_index": card_idx,
199
+ "jq_path": f".views[{view_idx}].sections[{section_idx}].cards[{card_idx}]",
200
+ "card_type": card.get("type"),
201
+ "card_config": card,
202
+ })
203
+ else:
204
+ # Flat view (masonry, panel, sidebar)
205
+ cards = view.get("cards", [])
206
+ for card_idx, card in enumerate(cards):
207
+ if not isinstance(card, dict):
208
+ continue
209
+ if _card_matches(card, entity_id, card_type, heading):
210
+ matches.append({
211
+ "view_index": view_idx,
212
+ "section_index": None,
213
+ "card_index": card_idx,
214
+ "jq_path": f".views[{view_idx}].cards[{card_idx}]",
215
+ "card_type": card.get("type"),
216
+ "card_config": card,
217
+ })
218
+
219
+ return matches
220
+
221
+
222
+ def _card_matches(
223
+ card: dict[str, Any],
224
+ entity_id: str | None,
225
+ card_type: str | None,
226
+ heading: str | None,
227
+ ) -> bool:
228
+ """Check if a card matches the search criteria."""
229
+ # Type filter
230
+ if card_type is not None:
231
+ if card.get("type") != card_type:
232
+ return False
233
+
234
+ # Entity filter (supports partial matching with *)
235
+ if entity_id is not None:
236
+ card_entity = card.get("entity", "")
237
+ # Also check entities list for cards that have multiple entities
238
+ card_entities = card.get("entities", [])
239
+ if isinstance(card_entities, list):
240
+ all_entities = [card_entity] + [
241
+ e.get("entity", e) if isinstance(e, dict) else e
242
+ for e in card_entities
243
+ ]
244
+ else:
245
+ all_entities = [card_entity]
246
+
247
+ # Support wildcard matching
248
+ if "*" in entity_id:
249
+ pattern = entity_id.replace(".", r"\.").replace("*", ".*")
250
+ if not any(re.match(pattern, e) for e in all_entities if e):
251
+ return False
252
+ else:
253
+ if entity_id not in all_entities:
254
+ return False
255
+
256
+ # Heading filter (for heading cards or section titles)
257
+ if heading is not None:
258
+ card_heading = card.get("heading", card.get("title", ""))
259
+ # Case-insensitive partial match
260
+ if heading.lower() not in card_heading.lower():
261
+ return False
262
+
263
+ return True
264
+
265
+
266
+ def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
267
+ """Register Home Assistant dashboard configuration tools."""
268
+
269
+ @mcp.tool(
270
+ annotations={
271
+ "idempotentHint": True,
272
+ "readOnlyHint": True,
273
+ "tags": ["dashboard"],
274
+ "title": "Get Dashboard",
275
+ }
276
+ )
277
+ @log_tool_usage
278
+ async def ha_config_get_dashboard(
279
+ url_path: Annotated[
280
+ str | None,
281
+ Field(
282
+ description="Dashboard URL path (e.g., 'lovelace-home'). "
283
+ "Use 'default' for default dashboard. "
284
+ "If omitted with list_only=True, lists all dashboards."
285
+ ),
286
+ ] = None,
287
+ list_only: Annotated[
288
+ bool,
289
+ Field(
290
+ description="If True, list all dashboards instead of getting config. "
291
+ "When True, url_path is ignored.",
292
+ ),
293
+ ] = False,
294
+ force_reload: Annotated[
295
+ bool, Field(description="Force reload from storage (bypass cache)")
296
+ ] = False,
297
+ ) -> dict[str, Any]:
298
+ """
299
+ Get dashboard info - list all dashboards or get config for a specific one.
300
+
301
+ Without url_path (or with list_only=True): Lists all storage-mode dashboards
302
+ with metadata including url_path, title, icon, admin requirements.
303
+
304
+ With url_path: Returns the full Lovelace dashboard configuration
305
+ including all views and cards.
306
+
307
+ EXAMPLES:
308
+ - List all dashboards: ha_config_get_dashboard(list_only=True)
309
+ - Get default dashboard: ha_config_get_dashboard(url_path="default")
310
+ - Get custom dashboard: ha_config_get_dashboard(url_path="lovelace-mobile")
311
+ - Force reload: ha_config_get_dashboard(url_path="lovelace-home", force_reload=True)
312
+
313
+ Note: YAML-mode dashboards (defined in configuration.yaml) are not included in list.
314
+ """
315
+ try:
316
+ # List mode
317
+ if list_only:
318
+ result = await client.send_websocket_message(
319
+ {"type": "lovelace/dashboards/list"}
320
+ )
321
+ if isinstance(result, dict) and "result" in result:
322
+ dashboards = result["result"]
323
+ elif isinstance(result, list):
324
+ dashboards = result
325
+ else:
326
+ dashboards = []
327
+
328
+ return {
329
+ "success": True,
330
+ "action": "list",
331
+ "dashboards": dashboards,
332
+ "count": len(dashboards),
333
+ }
334
+
335
+ # Get mode - build WebSocket message
336
+ data: dict[str, Any] = {"type": "lovelace/config", "force": force_reload}
337
+ # Handle "default" as special value for default dashboard
338
+ if url_path and url_path != "default":
339
+ data["url_path"] = url_path
340
+
341
+ response = await client.send_websocket_message(data)
342
+
343
+ # Check if request failed
344
+ if isinstance(response, dict) and not response.get("success", True):
345
+ error_msg = response.get("error", {})
346
+ if isinstance(error_msg, dict):
347
+ error_msg = error_msg.get("message", str(error_msg))
348
+ return {
349
+ "success": False,
350
+ "action": "get",
351
+ "url_path": url_path,
352
+ "error": str(error_msg),
353
+ "suggestions": [
354
+ "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
355
+ "Check if you have permission to access this dashboard",
356
+ "Use url_path='default' for default dashboard",
357
+ ],
358
+ }
359
+
360
+ # Extract config from WebSocket response
361
+ config = response.get("result") if isinstance(response, dict) else response
362
+
363
+ # Compute hash for optimistic locking in subsequent operations
364
+ config_hash = _compute_config_hash(config) if isinstance(config, dict) else None
365
+
366
+ # Calculate config size for progressive disclosure hint
367
+ config_size = len(json.dumps(config)) if isinstance(config, dict) else 0
368
+
369
+ result: dict[str, Any] = {
370
+ "success": True,
371
+ "action": "get",
372
+ "url_path": url_path,
373
+ "config": config,
374
+ "config_hash": config_hash,
375
+ "config_size_bytes": config_size,
376
+ }
377
+
378
+ # Add hint for large configs (progressive disclosure) - 10KB ≈ 2-3k tokens
379
+ if config_size >= 10000:
380
+ result["hint"] = (
381
+ f"Large config ({config_size:,} bytes). For edits, use "
382
+ "ha_dashboard_find_card() + ha_config_set_dashboard(jq_transform=...) "
383
+ "instead of full config replacement."
384
+ )
385
+
386
+ return result
387
+ except Exception as e:
388
+ logger.error(f"Error getting dashboard: {e}")
389
+ return {
390
+ "success": False,
391
+ "action": "get" if not list_only else "list",
392
+ "url_path": url_path,
393
+ "error": str(e),
394
+ "suggestions": [
395
+ "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
396
+ "Check if you have permission to access this dashboard",
397
+ "Use url_path='default' for default dashboard",
398
+ ],
399
+ }
400
+
401
+ @mcp.tool(
402
+ annotations={
403
+ "destructiveHint": True,
404
+ "tags": ["dashboard"],
405
+ "title": "Create or Update Dashboard",
406
+ }
407
+ )
408
+ @log_tool_usage
409
+ async def ha_config_set_dashboard(
410
+ url_path: Annotated[
411
+ str,
412
+ Field(
413
+ description="Unique URL path for dashboard (must contain hyphen, "
414
+ "e.g., 'my-dashboard', 'mobile-view')"
415
+ ),
416
+ ],
417
+ config: Annotated[
418
+ str | dict[str, Any] | None,
419
+ Field(
420
+ description="Dashboard configuration with views and cards. "
421
+ "Can be dict or JSON string. "
422
+ "Omit or set to None to create dashboard without initial config. "
423
+ "Mutually exclusive with jq_transform."
424
+ ),
425
+ ] = None,
426
+ jq_transform: Annotated[
427
+ str | None,
428
+ Field(
429
+ description="jq expression to transform existing dashboard config. "
430
+ "Mutually exclusive with config. Requires config_hash for validation. "
431
+ "Examples: '.views[0].sections[1].cards[0].icon = \"mdi:thermometer\"', "
432
+ "'.views[0].cards += [{\"type\": \"button\", \"entity\": \"light.bedroom\"}]', "
433
+ "'del(.views[0].sections[0].cards[2])'. "
434
+ "MULTI-OP: Chain with '|': 'del(.views[0].cards[2]) | .views[0].cards[0].icon = \"mdi:new\"'. "
435
+ "Use ha_dashboard_find_card() to get jq_path for targeted edits."
436
+ ),
437
+ ] = None,
438
+ config_hash: Annotated[
439
+ str | None,
440
+ Field(
441
+ description="Config hash from ha_config_get_dashboard for optimistic locking. "
442
+ "REQUIRED for jq_transform (validates dashboard unchanged). "
443
+ "Optional for config (validates before full replacement if provided)."
444
+ ),
445
+ ] = None,
446
+ title: Annotated[
447
+ str | None,
448
+ Field(description="Dashboard display name shown in sidebar"),
449
+ ] = None,
450
+ icon: Annotated[
451
+ str | None,
452
+ Field(
453
+ description="MDI icon name (e.g., 'mdi:home', 'mdi:cellphone'). "
454
+ "Defaults to 'mdi:view-dashboard'"
455
+ ),
456
+ ] = None,
457
+ require_admin: Annotated[
458
+ bool, Field(description="Restrict dashboard to admin users only")
459
+ ] = False,
460
+ show_in_sidebar: Annotated[
461
+ bool, Field(description="Show dashboard in sidebar navigation")
462
+ ] = True,
463
+ ) -> dict[str, Any]:
464
+ """
465
+ Create or update a Home Assistant dashboard.
466
+
467
+ Creates a new dashboard or updates an existing one with the provided configuration.
468
+ Supports two modes: full config replacement OR jq-based transformation.
469
+
470
+ IMPORTANT: url_path must contain a hyphen (-) to be valid.
471
+
472
+ WHEN TO USE WHICH MODE:
473
+ - jq_transform: Preferred for edits. Surgical changes, fewer tokens.
474
+ - config: New dashboards only, or full restructure. Replaces everything.
475
+
476
+ JQ TRANSFORM EXAMPLES:
477
+ - Update card icon: '.views[0].sections[1].cards[0].icon = "mdi:thermometer"'
478
+ - Add card: '.views[0].cards += [{"type": "button", "entity": "light.bedroom"}]'
479
+ - Delete card: 'del(.views[0].sections[0].cards[2])'
480
+ - Update by selection: '(.views[0].cards[] | select(.entity == "light.living_room")).icon = "mdi:lamp"'
481
+
482
+ MULTI-OPERATION (chain with |):
483
+ - Delete then update: 'del(.views[0].cards[2]) | .views[0].cards[0].icon = "mdi:new"'
484
+ - Multiple updates: '.views[0].cards[0].icon = "mdi:a" | .views[0].cards[1].icon = "mdi:b"'
485
+
486
+ IMPORTANT: After delete/add operations, indices shift! Subsequent jq_transform calls
487
+ must use fresh config_hash from ha_dashboard_find_card() or ha_config_get_dashboard()
488
+ to get updated structure. Chain multiple ops in ONE expression when possible.
489
+
490
+ TIP: Use ha_dashboard_find_card() to get the jq_path for any card.
491
+
492
+ MODERN DASHBOARD BEST PRACTICES (2024+):
493
+ - Use "sections" view type (default) with grid-based layouts
494
+ - Use "tile" cards as primary card type (replaces legacy entity/light/climate cards)
495
+ - Use "grid" cards for multi-column layouts within sections
496
+ - Create multiple views with navigation paths (avoid single-view endless scrolling)
497
+ - Use "area" cards with navigation for hierarchical organization
498
+
499
+ DISCOVERING ENTITY IDs FOR DASHBOARDS:
500
+ Do NOT guess entity IDs - use these tools to find exact entity IDs:
501
+ 1. ha_get_overview(include_entity_id=True) - Get all entities organized by domain/area
502
+ 2. ha_search_entities(query, domain_filter, area_filter) - Find specific entities
503
+ 3. ha_deep_search(query) - Comprehensive search across entities, areas, automations
504
+
505
+ If unsure about entity IDs, ALWAYS use one of these tools first.
506
+
507
+ DASHBOARD DOCUMENTATION:
508
+ - ha_get_dashboard_guide() - Complete guide (structure, views, cards, features, pitfalls)
509
+ - ha_get_card_types() - List of all 41 available card types
510
+ - ha_get_card_documentation(card_type) - Card-specific docs (e.g., "tile", "grid")
511
+
512
+ EXAMPLES:
513
+
514
+ Create empty dashboard:
515
+ ha_config_set_dashboard(
516
+ url_path="mobile-dashboard",
517
+ title="Mobile View",
518
+ icon="mdi:cellphone"
519
+ )
520
+
521
+ Create dashboard with modern sections view:
522
+ ha_config_set_dashboard(
523
+ url_path="home-dashboard",
524
+ title="Home Overview",
525
+ config={
526
+ "views": [{
527
+ "title": "Home",
528
+ "type": "sections",
529
+ "sections": [{
530
+ "title": "Climate",
531
+ "cards": [{
532
+ "type": "tile",
533
+ "entity": "climate.living_room",
534
+ "features": [{"type": "target-temperature"}]
535
+ }]
536
+ }]
537
+ }]
538
+ }
539
+ )
540
+
541
+ Update card using jq_transform (efficient for small changes):
542
+ ha_config_set_dashboard(
543
+ url_path="home-dashboard",
544
+ jq_transform='.views[0].sections[0].cards[0].features += [{"type": "climate-hvac-modes"}]'
545
+ )
546
+
547
+ Create strategy-based dashboard (auto-generated):
548
+ ha_config_set_dashboard(
549
+ url_path="my-home",
550
+ title="My Home",
551
+ config={
552
+ "strategy": {
553
+ "type": "home",
554
+ "favorite_entities": ["light.bedroom"]
555
+ }
556
+ }
557
+ )
558
+
559
+ Note: Strategy dashboards cannot be converted to custom dashboards via this tool.
560
+ Use the "Take Control" feature in the Home Assistant interface to convert them.
561
+
562
+ Update existing dashboard config:
563
+ ha_config_set_dashboard(
564
+ url_path="existing-dashboard",
565
+ config={
566
+ "views": [{
567
+ "title": "Updated View",
568
+ "type": "sections",
569
+ "sections": [{
570
+ "cards": [{"type": "markdown", "content": "Updated!"}]
571
+ }]
572
+ }]
573
+ }
574
+ )
575
+
576
+ Note: If dashboard exists, only the config is updated. To change metadata
577
+ (title, icon), use ha_config_update_dashboard_metadata().
578
+ """
579
+ try:
580
+ # Validate url_path contains hyphen
581
+ if "-" not in url_path:
582
+ return {
583
+ "success": False,
584
+ "action": "set",
585
+ "error": "url_path must contain a hyphen (-)",
586
+ "suggestions": [
587
+ f"Try '{url_path.replace('_', '-')}' instead",
588
+ "Use format like 'my-dashboard' or 'mobile-view'",
589
+ ],
590
+ }
591
+
592
+ # Validate mutual exclusivity of config and jq_transform
593
+ if config is not None and jq_transform is not None:
594
+ return {
595
+ "success": False,
596
+ "action": "set",
597
+ "error": "Cannot use both 'config' and 'jq_transform' parameters",
598
+ "suggestions": [
599
+ "Use 'config' for full replacement",
600
+ "Use 'jq_transform' for targeted changes to existing dashboard",
601
+ ],
602
+ }
603
+
604
+ # Handle jq_transform mode
605
+ if jq_transform is not None:
606
+ # config_hash is REQUIRED for jq_transform
607
+ if config_hash is None:
608
+ return {
609
+ "success": False,
610
+ "action": "jq_transform",
611
+ "url_path": url_path,
612
+ "error": "config_hash is required for jq_transform",
613
+ "suggestions": [
614
+ "Call ha_config_get_dashboard() or ha_dashboard_find_card() first",
615
+ "Use the config_hash from that response",
616
+ ],
617
+ }
618
+
619
+ # Fetch current dashboard config
620
+ get_data: dict[str, Any] = {"type": "lovelace/config", "force": True}
621
+ if url_path:
622
+ get_data["url_path"] = url_path
623
+
624
+ response = await client.send_websocket_message(get_data)
625
+
626
+ if isinstance(response, dict) and not response.get("success", True):
627
+ error_msg = response.get("error", {})
628
+ if isinstance(error_msg, dict):
629
+ error_msg = error_msg.get("message", str(error_msg))
630
+ return {
631
+ "success": False,
632
+ "action": "jq_transform",
633
+ "url_path": url_path,
634
+ "error": f"Dashboard not found or inaccessible: {error_msg}",
635
+ "suggestions": [
636
+ "jq_transform requires an existing dashboard",
637
+ "Use 'config' parameter to create a new dashboard",
638
+ "Verify dashboard exists with ha_config_get_dashboard(list_only=True)",
639
+ ],
640
+ }
641
+
642
+ current_config = response.get("result") if isinstance(response, dict) else response
643
+ if not isinstance(current_config, dict):
644
+ return {
645
+ "success": False,
646
+ "action": "jq_transform",
647
+ "url_path": url_path,
648
+ "error": "Current dashboard config is invalid",
649
+ "suggestions": ["Initialize dashboard with 'config' parameter first"],
650
+ }
651
+
652
+ # Validate config_hash for optimistic locking
653
+ current_hash = _compute_config_hash(current_config)
654
+ if current_hash != config_hash:
655
+ return {
656
+ "success": False,
657
+ "action": "jq_transform",
658
+ "url_path": url_path,
659
+ "error": "Dashboard modified since last read (conflict)",
660
+ "suggestions": [
661
+ "Call ha_config_get_dashboard() or ha_dashboard_find_card() again",
662
+ "Use the fresh config_hash from that response",
663
+ "Indices may have changed - re-locate cards with ha_dashboard_find_card()",
664
+ ],
665
+ }
666
+
667
+ # Apply jq transformation
668
+ transformed_config, error = _apply_jq_transform(current_config, jq_transform)
669
+ if error:
670
+ return {
671
+ "success": False,
672
+ "action": "jq_transform",
673
+ "url_path": url_path,
674
+ "error": error,
675
+ "suggestions": [
676
+ "Verify jq syntax: https://jqlang.github.io/jq/manual/",
677
+ "Use ha_dashboard_find_card() to get correct jq_path",
678
+ "Test expression locally: echo '<config>' | jq '<expression>'",
679
+ ],
680
+ }
681
+
682
+ # Save transformed config
683
+ save_data: dict[str, Any] = {
684
+ "type": "lovelace/config/save",
685
+ "config": transformed_config,
686
+ }
687
+ if url_path:
688
+ save_data["url_path"] = url_path
689
+
690
+ save_result = await client.send_websocket_message(save_data)
691
+
692
+ if isinstance(save_result, dict) and not save_result.get("success", True):
693
+ error_msg = save_result.get("error", {})
694
+ if isinstance(error_msg, dict):
695
+ error_msg = error_msg.get("message", str(error_msg))
696
+ return {
697
+ "success": False,
698
+ "action": "jq_transform",
699
+ "url_path": url_path,
700
+ "error": f"Failed to save transformed config: {error_msg}",
701
+ "suggestions": [
702
+ "jq expression may have produced invalid dashboard structure",
703
+ "Verify config format is valid Lovelace JSON",
704
+ ],
705
+ }
706
+
707
+ # Compute new hash for potential chaining
708
+ # transformed_config is guaranteed to be a dict here (validated above)
709
+ new_config_hash = _compute_config_hash(cast(dict[str, Any], transformed_config))
710
+
711
+ return {
712
+ "success": True,
713
+ "action": "jq_transform",
714
+ "url_path": url_path,
715
+ "config_hash": new_config_hash,
716
+ "jq_expression": jq_transform,
717
+ "message": f"Dashboard {url_path} updated via jq transform",
718
+ }
719
+
720
+ # Check if dashboard exists
721
+ result = await client.send_websocket_message(
722
+ {"type": "lovelace/dashboards/list"}
723
+ )
724
+ if isinstance(result, dict) and "result" in result:
725
+ existing_dashboards = result["result"]
726
+ elif isinstance(result, list):
727
+ existing_dashboards = result
728
+ else:
729
+ existing_dashboards = []
730
+ dashboard_exists = any(
731
+ d.get("url_path") == url_path for d in existing_dashboards
732
+ )
733
+
734
+ # If dashboard doesn't exist, create it
735
+ dashboard_id = None
736
+ if not dashboard_exists:
737
+ # Use provided title or generate from url_path
738
+ dashboard_title = title or url_path.replace("-", " ").title()
739
+
740
+ # Build create message
741
+ create_data: dict[str, Any] = {
742
+ "type": "lovelace/dashboards/create",
743
+ "url_path": url_path,
744
+ "title": dashboard_title,
745
+ "require_admin": require_admin,
746
+ "show_in_sidebar": show_in_sidebar,
747
+ }
748
+ if icon:
749
+ create_data["icon"] = icon
750
+ create_result = await client.send_websocket_message(create_data)
751
+
752
+ # Check if dashboard creation was successful
753
+ if isinstance(create_result, dict) and not create_result.get(
754
+ "success", True
755
+ ):
756
+ error_msg = create_result.get("error", {})
757
+ if isinstance(error_msg, dict):
758
+ error_msg = error_msg.get("message", str(error_msg))
759
+ return {
760
+ "success": False,
761
+ "action": "create",
762
+ "url_path": url_path,
763
+ "error": str(error_msg),
764
+ }
765
+
766
+ # Extract dashboard ID from create response
767
+ if isinstance(create_result, dict) and "result" in create_result:
768
+ dashboard_info = create_result["result"]
769
+ dashboard_id = dashboard_info.get("id")
770
+ elif isinstance(create_result, dict):
771
+ dashboard_id = create_result.get("id")
772
+ else:
773
+ # If dashboard already exists, get its ID from the list
774
+ for dashboard in existing_dashboards:
775
+ if dashboard.get("url_path") == url_path:
776
+ dashboard_id = dashboard.get("id")
777
+ break
778
+
779
+ # Set config if provided
780
+ config_updated = False
781
+ existing_config_size = 0
782
+ hint = None
783
+
784
+ if config is not None:
785
+ parsed_config = parse_json_param(config, "config")
786
+ if parsed_config is None or not isinstance(parsed_config, dict):
787
+ return {
788
+ "success": False,
789
+ "action": "set",
790
+ "error": "Config parameter must be a dict/object",
791
+ "provided_type": type(parsed_config).__name__,
792
+ }
793
+
794
+ config_dict = cast(dict[str, Any], parsed_config)
795
+
796
+ # For existing dashboards, optionally validate config_hash and warn on large replacement
797
+ if dashboard_exists:
798
+ # Fetch current config for validation/comparison
799
+ get_data: dict[str, Any] = {"type": "lovelace/config", "force": True}
800
+ if url_path:
801
+ get_data["url_path"] = url_path
802
+ current_response = await client.send_websocket_message(get_data)
803
+ current_config = (
804
+ current_response.get("result")
805
+ if isinstance(current_response, dict)
806
+ else current_response
807
+ )
808
+
809
+ if isinstance(current_config, dict):
810
+ existing_config_size = len(json.dumps(current_config))
811
+
812
+ # Optional config_hash validation for full replacement
813
+ if config_hash is not None:
814
+ current_hash = _compute_config_hash(current_config)
815
+ if current_hash != config_hash:
816
+ return {
817
+ "success": False,
818
+ "action": "set",
819
+ "url_path": url_path,
820
+ "error": "Dashboard modified since last read (conflict)",
821
+ "suggestions": [
822
+ "Call ha_config_get_dashboard() again",
823
+ "Use the fresh config_hash, or omit config_hash to force replace",
824
+ ],
825
+ }
826
+
827
+ # Soft warning for large config full replacement (10KB ≈ 2-3k tokens)
828
+ if existing_config_size >= 10000:
829
+ hint = (
830
+ f"Replaced large config ({existing_config_size:,} bytes). "
831
+ "Consider jq_transform for targeted edits."
832
+ )
833
+
834
+ # Build save config message
835
+ config_save_data: dict[str, Any] = {
836
+ "type": "lovelace/config/save",
837
+ "config": config_dict,
838
+ }
839
+ if url_path:
840
+ config_save_data["url_path"] = url_path
841
+ save_result = await client.send_websocket_message(config_save_data)
842
+
843
+ # Check if save failed
844
+ if isinstance(save_result, dict) and not save_result.get(
845
+ "success", True
846
+ ):
847
+ error_msg = save_result.get("error", {})
848
+ if isinstance(error_msg, dict):
849
+ error_msg = error_msg.get("message", str(error_msg))
850
+ return {
851
+ "success": False,
852
+ "action": "set",
853
+ "url_path": url_path,
854
+ "error": f"Failed to save dashboard config: {error_msg}",
855
+ "suggestions": [
856
+ "Verify config format is valid Lovelace JSON",
857
+ "Check that you have admin permissions",
858
+ "Ensure all entity IDs in config exist",
859
+ ],
860
+ }
861
+
862
+ config_updated = True
863
+
864
+ result_dict: dict[str, Any] = {
865
+ "success": True,
866
+ "action": "create" if not dashboard_exists else "update",
867
+ "url_path": url_path,
868
+ "dashboard_id": dashboard_id,
869
+ "dashboard_created": not dashboard_exists,
870
+ "config_updated": config_updated,
871
+ "message": f"Dashboard {url_path} {'created' if not dashboard_exists else 'updated'} successfully",
872
+ }
873
+
874
+ if hint:
875
+ result_dict["hint"] = hint
876
+
877
+ return result_dict
878
+
879
+ except Exception as e:
880
+ logger.error(f"Error setting dashboard: {e}")
881
+ return {
882
+ "success": False,
883
+ "action": "set",
884
+ "url_path": url_path,
885
+ "error": str(e),
886
+ "suggestions": [
887
+ "Ensure url_path is unique (not already in use for different dashboard type)",
888
+ "Verify url_path contains a hyphen",
889
+ "Check that you have admin permissions",
890
+ "Verify config format is valid Lovelace JSON",
891
+ ],
892
+ }
893
+
894
+ @mcp.tool(
895
+ annotations={
896
+ "destructiveHint": True,
897
+ "tags": ["dashboard"],
898
+ "title": "Update Dashboard Metadata",
899
+ }
900
+ )
901
+ @log_tool_usage
902
+ async def ha_config_update_dashboard_metadata(
903
+ dashboard_id: Annotated[
904
+ str, Field(description="Dashboard ID (typically same as url_path)")
905
+ ],
906
+ title: Annotated[str | None, Field(description="New dashboard title")] = None,
907
+ icon: Annotated[str | None, Field(description="New MDI icon name")] = None,
908
+ require_admin: Annotated[
909
+ bool | None, Field(description="Update admin requirement")
910
+ ] = None,
911
+ show_in_sidebar: Annotated[
912
+ bool | None, Field(description="Update sidebar visibility")
913
+ ] = None,
914
+ ) -> dict[str, Any]:
915
+ """
916
+ Update dashboard metadata (title, icon, permissions) without changing content.
917
+
918
+ Updates dashboard properties without modifying the actual configuration
919
+ (views/cards). At least one field must be provided.
920
+
921
+ EXAMPLES:
922
+
923
+ Change dashboard title:
924
+ ha_config_update_dashboard_metadata(
925
+ dashboard_id="mobile-dashboard",
926
+ title="Mobile View v2"
927
+ )
928
+
929
+ Update multiple properties:
930
+ ha_config_update_dashboard_metadata(
931
+ dashboard_id="admin-panel",
932
+ title="Admin Dashboard",
933
+ icon="mdi:shield-account",
934
+ require_admin=True
935
+ )
936
+
937
+ Hide from sidebar:
938
+ ha_config_update_dashboard_metadata(
939
+ dashboard_id="hidden-dashboard",
940
+ show_in_sidebar=False
941
+ )
942
+ """
943
+ if all(x is None for x in [title, icon, require_admin, show_in_sidebar]):
944
+ return {
945
+ "success": False,
946
+ "action": "update_metadata",
947
+ "error": "At least one field must be provided to update",
948
+ }
949
+
950
+ try:
951
+ # Build update message
952
+ update_data: dict[str, Any] = {
953
+ "type": "lovelace/dashboards/update",
954
+ "dashboard_id": dashboard_id,
955
+ }
956
+ if title is not None:
957
+ update_data["title"] = title
958
+ if icon is not None:
959
+ update_data["icon"] = icon
960
+ if require_admin is not None:
961
+ update_data["require_admin"] = require_admin
962
+ if show_in_sidebar is not None:
963
+ update_data["show_in_sidebar"] = show_in_sidebar
964
+
965
+ result = await client.send_websocket_message(update_data)
966
+
967
+ # Check if update failed
968
+ if isinstance(result, dict) and not result.get("success", True):
969
+ error_msg = result.get("error", {})
970
+ if isinstance(error_msg, dict):
971
+ error_msg = error_msg.get("message", str(error_msg))
972
+ return {
973
+ "success": False,
974
+ "action": "update_metadata",
975
+ "dashboard_id": dashboard_id,
976
+ "error": str(error_msg),
977
+ "suggestions": [
978
+ "Verify dashboard ID exists using ha_config_get_dashboard(list_only=True)",
979
+ "Check that you have admin permissions",
980
+ ],
981
+ }
982
+
983
+ return {
984
+ "success": True,
985
+ "action": "update_metadata",
986
+ "dashboard_id": dashboard_id,
987
+ "updated_fields": {
988
+ k: v
989
+ for k, v in {
990
+ "title": title,
991
+ "icon": icon,
992
+ "require_admin": require_admin,
993
+ "show_in_sidebar": show_in_sidebar,
994
+ }.items()
995
+ if v is not None
996
+ },
997
+ "dashboard": result,
998
+ }
999
+ except Exception as e:
1000
+ logger.error(f"Error updating dashboard metadata: {e}")
1001
+ return {
1002
+ "success": False,
1003
+ "action": "update_metadata",
1004
+ "dashboard_id": dashboard_id,
1005
+ "error": str(e),
1006
+ "suggestions": [
1007
+ "Verify dashboard ID exists using ha_config_get_dashboard(list_only=True)",
1008
+ "Check that you have admin permissions",
1009
+ ],
1010
+ }
1011
+
1012
+ @mcp.tool(
1013
+ annotations={
1014
+ "destructiveHint": True,
1015
+ "idempotentHint": True,
1016
+ "tags": ["dashboard"],
1017
+ "title": "Delete Dashboard",
1018
+ }
1019
+ )
1020
+ @log_tool_usage
1021
+ async def ha_config_delete_dashboard(
1022
+ dashboard_id: Annotated[
1023
+ str,
1024
+ Field(description="Dashboard ID to delete (typically same as url_path)"),
1025
+ ],
1026
+ ) -> dict[str, Any]:
1027
+ """
1028
+ Delete a storage-mode dashboard completely.
1029
+
1030
+ WARNING: This permanently deletes the dashboard and all its configuration.
1031
+ Cannot be undone. Does not work on YAML-mode dashboards.
1032
+
1033
+ EXAMPLES:
1034
+ - Delete dashboard: ha_config_delete_dashboard("mobile-dashboard")
1035
+
1036
+ Note: The default dashboard cannot be deleted via this method.
1037
+ """
1038
+ try:
1039
+ response = await client.send_websocket_message(
1040
+ {"type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id}
1041
+ )
1042
+
1043
+ # Check response for error indication
1044
+ if isinstance(response, dict) and not response.get("success", True):
1045
+ error_msg = response.get("error", {})
1046
+ if isinstance(error_msg, dict):
1047
+ error_str = error_msg.get("message", str(error_msg))
1048
+ else:
1049
+ error_str = str(error_msg)
1050
+
1051
+ logger.error(f"Error deleting dashboard: {error_str}")
1052
+
1053
+ # If the error is "not found" / "doesn't exist", treat as success (idempotent)
1054
+ if (
1055
+ "unable to find" in error_str.lower()
1056
+ or "not found" in error_str.lower()
1057
+ ):
1058
+ return {
1059
+ "success": True,
1060
+ "action": "delete",
1061
+ "dashboard_id": dashboard_id,
1062
+ "message": "Dashboard already deleted or does not exist",
1063
+ }
1064
+
1065
+ # For other errors, return failure
1066
+ return {
1067
+ "success": False,
1068
+ "action": "delete",
1069
+ "dashboard_id": dashboard_id,
1070
+ "error": error_str,
1071
+ "suggestions": [
1072
+ "Verify dashboard exists and is storage-mode",
1073
+ "Check that you have admin permissions",
1074
+ "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
1075
+ "Cannot delete YAML-mode or default dashboard",
1076
+ ],
1077
+ }
1078
+
1079
+ # Delete successful
1080
+ return {
1081
+ "success": True,
1082
+ "action": "delete",
1083
+ "dashboard_id": dashboard_id,
1084
+ "message": "Dashboard deleted successfully",
1085
+ }
1086
+ except Exception as e:
1087
+ error_str = str(e)
1088
+ logger.error(f"Error deleting dashboard: {error_str}")
1089
+
1090
+ # If the error is "not found" / "doesn't exist", treat as success (idempotent)
1091
+ if (
1092
+ "unable to find" in error_str.lower()
1093
+ or "not found" in error_str.lower()
1094
+ ):
1095
+ return {
1096
+ "success": True,
1097
+ "action": "delete",
1098
+ "dashboard_id": dashboard_id,
1099
+ "message": "Dashboard already deleted or does not exist",
1100
+ }
1101
+
1102
+ # For other errors, return failure
1103
+ return {
1104
+ "success": False,
1105
+ "action": "delete",
1106
+ "dashboard_id": dashboard_id,
1107
+ "error": error_str,
1108
+ "suggestions": [
1109
+ "Verify dashboard exists and is storage-mode",
1110
+ "Check that you have admin permissions",
1111
+ "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
1112
+ "Cannot delete YAML-mode or default dashboard",
1113
+ ],
1114
+ }
1115
+
1116
+ @mcp.tool(
1117
+ annotations={
1118
+ "idempotentHint": True,
1119
+ "readOnlyHint": True,
1120
+ "tags": ["dashboard", "docs", "guide"],
1121
+ "title": "Get Dashboard Guide",
1122
+ }
1123
+ )
1124
+ @log_tool_usage
1125
+ async def ha_get_dashboard_guide() -> dict[str, Any]:
1126
+ """
1127
+ Get comprehensive guide for designing Home Assistant dashboards.
1128
+
1129
+ Covers:
1130
+ - Part 1: Dashboard structure, views, sections, navigation
1131
+ - Part 2: Built-in cards (tile, grid, button), features, actions
1132
+ - Part 3: Custom cards (JavaScript modules, registration)
1133
+ - Part 4: CSS styling (themes, card-mod patterns)
1134
+ - Part 5: HACS integration (finding/installing community cards)
1135
+ - Part 6: Complete examples and workflows
1136
+ - Part 7: Visual iteration with Playwright/browser MCP for screenshots
1137
+
1138
+ Use this guide before designing dashboards to understand
1139
+ all available options, from built-in cards to custom resources.
1140
+
1141
+ Related tools:
1142
+ - ha_create_dashboard_resource: Host inline JS/CSS
1143
+ - ha_hacs_search / ha_hacs_download: Community cards
1144
+
1145
+ EXAMPLES:
1146
+ - Get full guide: ha_get_dashboard_guide()
1147
+ """
1148
+ try:
1149
+ resources_dir = _get_resources_dir()
1150
+ guide_path = resources_dir / "dashboard_guide.md"
1151
+ guide_content = guide_path.read_text()
1152
+ return {
1153
+ "success": True,
1154
+ "action": "get_guide",
1155
+ "guide": guide_content,
1156
+ "format": "markdown",
1157
+ }
1158
+ except Exception as e:
1159
+ logger.error(f"Error reading dashboard guide: {e}")
1160
+ return {
1161
+ "success": False,
1162
+ "action": "get_guide",
1163
+ "error": str(e),
1164
+ "suggestions": [
1165
+ "Ensure dashboard_guide.md exists in resources directory",
1166
+ f"Attempted path: {resources_dir / 'dashboard_guide.md' if 'resources_dir' in locals() else 'unknown'}",
1167
+ ],
1168
+ }
1169
+
1170
+ @mcp.tool(
1171
+ annotations={
1172
+ "idempotentHint": True,
1173
+ "readOnlyHint": True,
1174
+ "tags": ["dashboard", "docs"],
1175
+ "title": "Get Card Types",
1176
+ }
1177
+ )
1178
+ @log_tool_usage
1179
+ async def ha_get_card_types() -> dict[str, Any]:
1180
+ """
1181
+ Get list of all available Home Assistant dashboard card types.
1182
+
1183
+ Returns all 41 card types that can be used in dashboard configurations.
1184
+
1185
+ EXAMPLES:
1186
+ - Get card types: ha_get_card_types()
1187
+
1188
+ Use ha_get_card_documentation(card_type) to get detailed docs for a specific card.
1189
+ """
1190
+ try:
1191
+ resources_dir = _get_resources_dir()
1192
+ types_path = resources_dir / "card_types.json"
1193
+ card_types_data = json.loads(types_path.read_text())
1194
+ return {
1195
+ "success": True,
1196
+ "action": "get_card_types",
1197
+ "card_types": card_types_data["card_types"],
1198
+ "total_count": card_types_data["total_count"],
1199
+ "documentation_base_url": card_types_data["documentation_base_url"],
1200
+ }
1201
+ except Exception as e:
1202
+ logger.error(f"Error reading card types: {e}")
1203
+ return {
1204
+ "success": False,
1205
+ "action": "get_card_types",
1206
+ "error": str(e),
1207
+ "suggestions": [
1208
+ "Ensure card_types.json exists in resources directory",
1209
+ f"Attempted path: {resources_dir / 'card_types.json' if 'resources_dir' in locals() else 'unknown'}",
1210
+ ],
1211
+ }
1212
+
1213
+ @mcp.tool(
1214
+ annotations={
1215
+ "idempotentHint": True,
1216
+ "readOnlyHint": True,
1217
+ "tags": ["dashboard", "docs"],
1218
+ "title": "Get Card Documentation",
1219
+ }
1220
+ )
1221
+ @log_tool_usage
1222
+ async def ha_get_card_documentation(
1223
+ card_type: Annotated[
1224
+ str,
1225
+ Field(
1226
+ description="Card type name (e.g., 'light', 'thermostat', 'entity'). "
1227
+ "Use ha_get_card_types() to see all available types."
1228
+ ),
1229
+ ],
1230
+ ) -> dict[str, Any]:
1231
+ """
1232
+ Fetch detailed documentation for a specific dashboard card type.
1233
+
1234
+ Returns the official Home Assistant documentation for the specified card type
1235
+ in markdown format, fetched directly from the Home Assistant documentation repository.
1236
+
1237
+ EXAMPLES:
1238
+ - Get light card docs: ha_get_card_documentation("light")
1239
+ - Get thermostat card docs: ha_get_card_documentation("thermostat")
1240
+ - Get entity card docs: ha_get_card_documentation("entity")
1241
+
1242
+ First use ha_get_card_types() to see all 41 available card types.
1243
+ """
1244
+ try:
1245
+ # Validate card type exists
1246
+ resources_dir = _get_resources_dir()
1247
+ types_path = resources_dir / "card_types.json"
1248
+ card_types_data = json.loads(types_path.read_text())
1249
+
1250
+ if card_type not in card_types_data["card_types"]:
1251
+ available = ", ".join(card_types_data["card_types"][:10])
1252
+ return {
1253
+ "success": False,
1254
+ "action": "get_card_documentation",
1255
+ "card_type": card_type,
1256
+ "error": f"Unknown card type '{card_type}'",
1257
+ "suggestions": [
1258
+ f"Available types include: {available}...",
1259
+ "Use ha_get_card_types() to see full list of 41 card types",
1260
+ ],
1261
+ }
1262
+
1263
+ # Fetch documentation from GitHub
1264
+ doc_url = f"{CARD_DOCS_BASE_URL}/{card_type}.markdown"
1265
+
1266
+ async with httpx.AsyncClient(timeout=10.0) as http_client:
1267
+ response = await http_client.get(doc_url)
1268
+ response.raise_for_status()
1269
+ return {
1270
+ "success": True,
1271
+ "action": "get_card_documentation",
1272
+ "card_type": card_type,
1273
+ "documentation": response.text,
1274
+ "format": "markdown",
1275
+ "source_url": doc_url,
1276
+ }
1277
+ except httpx.HTTPStatusError as e:
1278
+ logger.error(f"Failed to fetch card docs for {card_type}: {e}")
1279
+ return {
1280
+ "success": False,
1281
+ "action": "get_card_documentation",
1282
+ "card_type": card_type,
1283
+ "error": f"Failed to fetch documentation (HTTP {e.response.status_code})",
1284
+ "source_url": doc_url,
1285
+ }
1286
+ except Exception as e:
1287
+ logger.error(f"Error fetching card docs for {card_type}: {e}")
1288
+ return {
1289
+ "success": False,
1290
+ "action": "get_card_documentation",
1291
+ "card_type": card_type,
1292
+ "error": str(e),
1293
+ }
1294
+
1295
+
1296
+ # =========================================================================
1297
+ # Dashboard Resource Management Tools
1298
+ # =========================================================================
1299
+ # Resource tools have been moved to tools_resources.py for better organization.
1300
+ # Available tools:
1301
+ # - ha_config_list_dashboard_resources: List all resources
1302
+ # - ha_config_set_inline_dashboard_resource: Create/update inline code resources
1303
+ # - ha_config_set_dashboard_resource: Create/update URL-based resources
1304
+ # - ha_config_delete_dashboard_resource: Delete resources
1305
+ # =========================================================================
1306
+
1307
+ # =========================================================================
1308
+ # Card Search Tool (partial update tools - controlled by feature flag)
1309
+ # Card add/update/remove replaced by jq_transform in ha_config_set_dashboard
1310
+ # =========================================================================
1311
+
1312
+ # Check feature flag for partial update tools (lazy check, default enabled)
1313
+ try:
1314
+ settings = get_global_settings()
1315
+ if not settings.enable_dashboard_partial_tools:
1316
+ return # Skip registering find_card if partial tools disabled
1317
+ except Exception:
1318
+ pass # Default: register the tool if settings unavailable
1319
+
1320
+ @mcp.tool(
1321
+ annotations={
1322
+ "idempotentHint": True,
1323
+ "readOnlyHint": True,
1324
+ "tags": ["dashboard", "card"],
1325
+ "title": "Find Dashboard Card",
1326
+ }
1327
+ )
1328
+ @log_tool_usage
1329
+ async def ha_dashboard_find_card(
1330
+ url_path: Annotated[
1331
+ str | None,
1332
+ Field(description="Dashboard URL path, e.g. 'lovelace-home'. Omit for default."),
1333
+ ] = None,
1334
+ entity_id: Annotated[
1335
+ str | None,
1336
+ Field(
1337
+ description="Find cards by entity ID. Supports wildcards, e.g. 'sensor.temperature_*'. "
1338
+ "Matches cards with this entity in 'entity' or 'entities' field."
1339
+ ),
1340
+ ] = None,
1341
+ card_type: Annotated[
1342
+ str | None,
1343
+ Field(description="Find cards by type, e.g. 'tile', 'button', 'heading'."),
1344
+ ] = None,
1345
+ heading: Annotated[
1346
+ str | None,
1347
+ Field(
1348
+ description="Find cards by heading/title text (case-insensitive partial match). "
1349
+ "Useful for finding section headings (type: 'heading')."
1350
+ ),
1351
+ ] = None,
1352
+ include_config: Annotated[
1353
+ bool,
1354
+ Field(description="Include full card configuration in results (increases output size)."),
1355
+ ] = False,
1356
+ ) -> dict[str, Any]:
1357
+ """
1358
+ Find cards in a dashboard by entity_id, type, or heading text.
1359
+
1360
+ Returns card locations (view_index, section_index, card_index) and jq_path
1361
+ for use with ha_config_set_dashboard(jq_transform=...).
1362
+
1363
+ Use this tool BEFORE targeted updates to find exact card positions without
1364
+ manually parsing the full dashboard config.
1365
+
1366
+ SEARCH CRITERIA (at least one required):
1367
+ - entity_id: Match cards containing this entity (supports wildcards with *)
1368
+ - card_type: Match cards of this type (e.g., 'tile', 'button', 'heading')
1369
+ - heading: Match cards with this text in heading/title (partial, case-insensitive)
1370
+
1371
+ Multiple criteria are AND-ed together.
1372
+
1373
+ EXAMPLES:
1374
+
1375
+ Find all tile cards:
1376
+ ha_dashboard_find_card(url_path="my-dashboard", card_type="tile")
1377
+
1378
+ Find cards for a specific entity:
1379
+ ha_dashboard_find_card(url_path="my-dashboard", entity_id="light.living_room")
1380
+
1381
+ Find all temperature sensors (wildcard):
1382
+ ha_dashboard_find_card(url_path="my-dashboard", entity_id="sensor.temperature_*")
1383
+
1384
+ Find the "Climate" section heading:
1385
+ ha_dashboard_find_card(url_path="my-dashboard", heading="Climate", card_type="heading")
1386
+
1387
+ WORKFLOW EXAMPLE:
1388
+ 1. find = ha_dashboard_find_card(url_path="my-dash", entity_id="light.bedroom")
1389
+ 2. # Use jq_path and config_hash from result to update:
1390
+ 3. ha_config_set_dashboard(
1391
+ url_path="my-dash",
1392
+ config_hash=find["config_hash"],
1393
+ jq_transform=f'{find["matches"][0]["jq_path"]}.icon = "mdi:lamp"'
1394
+ )
1395
+ """
1396
+ try:
1397
+ # Validate at least one search criteria
1398
+ if entity_id is None and card_type is None and heading is None:
1399
+ return {
1400
+ "success": False,
1401
+ "action": "find_card",
1402
+ "error": "At least one search criteria required",
1403
+ "suggestions": [
1404
+ "Provide entity_id, card_type, or heading parameter",
1405
+ "Use entity_id='sensor.*' to find all sensor cards",
1406
+ "Use card_type='heading' to find section headings",
1407
+ ],
1408
+ }
1409
+
1410
+ # Fetch dashboard config
1411
+ get_data: dict[str, Any] = {"type": "lovelace/config", "force": True}
1412
+ if url_path:
1413
+ get_data["url_path"] = url_path
1414
+
1415
+ response = await client.send_websocket_message(get_data)
1416
+
1417
+ if isinstance(response, dict) and not response.get("success", True):
1418
+ error_msg = response.get("error", {})
1419
+ if isinstance(error_msg, dict):
1420
+ error_msg = error_msg.get("message", str(error_msg))
1421
+ return {
1422
+ "success": False,
1423
+ "action": "find_card",
1424
+ "url_path": url_path,
1425
+ "error": f"Failed to get dashboard: {error_msg}",
1426
+ "suggestions": [
1427
+ "Verify dashboard exists with ha_config_get_dashboard(list_only=True)",
1428
+ "Check HA connection",
1429
+ ],
1430
+ }
1431
+
1432
+ config = response.get("result") if isinstance(response, dict) else response
1433
+ if not isinstance(config, dict):
1434
+ return {
1435
+ "success": False,
1436
+ "action": "find_card",
1437
+ "url_path": url_path,
1438
+ "error": "Dashboard config is empty or invalid",
1439
+ "suggestions": ["Initialize dashboard with ha_config_set_dashboard"],
1440
+ }
1441
+
1442
+ # Check for strategy dashboard
1443
+ if "strategy" in config:
1444
+ return {
1445
+ "success": False,
1446
+ "action": "find_card",
1447
+ "url_path": url_path,
1448
+ "error": "Strategy dashboards have no explicit cards to search",
1449
+ "suggestions": [
1450
+ "Use 'Take Control' in HA UI to convert to editable",
1451
+ "Or create a non-strategy dashboard",
1452
+ ],
1453
+ }
1454
+
1455
+ # Find matching cards
1456
+ matches = _find_cards_in_config(config, entity_id, card_type, heading)
1457
+
1458
+ # Optionally strip config to reduce output size
1459
+ if not include_config:
1460
+ for match in matches:
1461
+ del match["card_config"]
1462
+
1463
+ # Compute config hash for potential follow-up operations
1464
+ config_hash = _compute_config_hash(config)
1465
+
1466
+ return {
1467
+ "success": True,
1468
+ "action": "find_card",
1469
+ "url_path": url_path,
1470
+ "config_hash": config_hash,
1471
+ "search_criteria": {
1472
+ "entity_id": entity_id,
1473
+ "card_type": card_type,
1474
+ "heading": heading,
1475
+ },
1476
+ "matches": matches,
1477
+ "match_count": len(matches),
1478
+ "hint": "Use jq_path with ha_config_set_dashboard(jq_transform=...) for targeted updates"
1479
+ if matches else "No matches found. Try broader search criteria.",
1480
+ }
1481
+
1482
+ except asyncio.CancelledError:
1483
+ raise
1484
+ except Exception as e:
1485
+ logger.error(
1486
+ f"Error finding card: url_path={url_path}, "
1487
+ f"entity_id={entity_id}, card_type={card_type}, heading={heading}, "
1488
+ f"error={e}",
1489
+ exc_info=True,
1490
+ )
1491
+ return {
1492
+ "success": False,
1493
+ "action": "find_card",
1494
+ "url_path": url_path,
1495
+ "error": str(e) if str(e) else f"{type(e).__name__} (no details)",
1496
+ "error_type": type(e).__name__,
1497
+ "suggestions": [
1498
+ "Check HA connection",
1499
+ "Verify dashboard with ha_config_get_dashboard(list_only=True)",
1500
+ ],
1501
+ }
1502
+