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