Skip to content

Commit e6df821

Browse files
kingpanther13claudegemini-code-assist[bot]
authored
fix: allow editing default dashboard without hyphen in url_path (#591) (#592)
* fix: allow editing default dashboard without hyphen in url_path (#591) The `ha_config_set_dashboard` tool rejected url_paths without hyphens (like "lovelace") even for existing dashboards. The hyphen requirement only applies when creating new dashboards in Home Assistant. - Move hyphen validation from blanket check to create-only path - Add "default" as alias for the default "lovelace" dashboard - Update tool description and dashboard guide documentation - Add E2E test for default dashboard editing Closes #591 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update src/ha_mcp/tools/tools_config_dashboards.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix: keep early hyphen validation with lovelace exception, fix E2E tests Move hyphen validation back to early-exit position (before WebSocket calls) to avoid NameError on invalid url_paths, but add exception for "lovelace" so the default dashboard can be edited. Rewrite E2E test to work on fresh HA instances where the default dashboard isn't a storage-mode dashboard - assert that "lovelace" and "default" are not rejected by hyphen validation rather than trying to actually edit the default dashboard config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: treat lovelace as always-existing to prevent create attempt The built-in default dashboard ("lovelace") isn't listed by lovelace/dashboards/list on fresh HA instances, causing the code to try creating a new dashboard with that url_path. HA's own API then rejects it with its own hyphen validation. Force dashboard_exists=True when url_path is "lovelace" so the code never attempts to create a dashboard that already exists as a built-in. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 37fd763 commit e6df821

3 files changed

Lines changed: 193 additions & 79 deletions

File tree

src/ha_mcp/resources/dashboard_guide.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ Complete guide for designing Home Assistant dashboards: structure, built-in card
5353

5454
### Critical Validation Rules
5555

56-
**url_path MUST contain hyphen (-)**
57-
- Valid: "my-dashboard"
56+
**New dashboard url_path must contain hyphen (-)**
57+
- Valid: "my-dashboard", "mobile-view"
5858
- Invalid: "mydashboard" → REJECTED
59+
- Exception: "lovelace" and "default" target the built-in default dashboard
5960

6061
**Dashboard ID vs url_path:**
6162
- `dashboard_id`: Internal identifier (returned on create, used for update/delete)
@@ -508,7 +509,7 @@ card_config = {
508509
509510
| Issue | Solution |
510511
|-------|----------|
511-
| url_path rejected | Add hyphen: "my-dashboard" not "mydashboard" |
512+
| url_path rejected | New dashboards need hyphen: "my-dashboard" not "mydashboard". Use "lovelace" or "default" for the default dashboard. |
512513
| Entity not found | Use full ID: "light.living_room" not "living_room" |
513514
| Features not working | Match feature type to entity domain |
514515
| Custom card not loading | Check resource type is "module", verify URL |

src/ha_mcp/tools/tools_config_dashboards.py

Lines changed: 88 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
from pydantic import Field
1717

1818
from ..config import get_global_settings
19-
from ..utils.python_sandbox import PythonSandboxError, get_security_documentation, safe_execute
19+
from ..utils.python_sandbox import (
20+
PythonSandboxError,
21+
get_security_documentation,
22+
safe_execute,
23+
)
2024
from .helpers import log_tool_usage
2125
from .util_helpers import parse_json_param
2226

@@ -25,6 +29,7 @@
2529
# Try to import jq - it's not available on Windows ARM64
2630
try:
2731
import jq # noqa: F401 - Used to check availability, re-imported in function
32+
2833
JQ_AVAILABLE = True
2934
except ImportError:
3035
JQ_AVAILABLE = False
@@ -98,7 +103,9 @@ async def _verify_config_unchanged(
98103
get_data["url_path"] = url_path
99104

100105
result = await client.send_websocket_message(get_data)
101-
current_config = result.get("result", result) if isinstance(result, dict) else result
106+
current_config = (
107+
result.get("result", result) if isinstance(result, dict) else result
108+
)
102109

103110
if not isinstance(current_config, dict):
104111
return {"success": True} # Can't verify, proceed anyway
@@ -193,29 +200,33 @@ def _find_cards_in_config(
193200
if not isinstance(card, dict):
194201
continue
195202
if _card_matches(card, entity_id, card_type, heading):
196-
matches.append({
197-
"view_index": view_idx,
198-
"section_index": section_idx,
199-
"card_index": card_idx,
200-
"jq_path": f".views[{view_idx}].sections[{section_idx}].cards[{card_idx}]",
201-
"card_type": card.get("type"),
202-
"card_config": card,
203-
})
203+
matches.append(
204+
{
205+
"view_index": view_idx,
206+
"section_index": section_idx,
207+
"card_index": card_idx,
208+
"jq_path": f".views[{view_idx}].sections[{section_idx}].cards[{card_idx}]",
209+
"card_type": card.get("type"),
210+
"card_config": card,
211+
}
212+
)
204213
else:
205214
# Flat view (masonry, panel, sidebar)
206215
cards = view.get("cards", [])
207216
for card_idx, card in enumerate(cards):
208217
if not isinstance(card, dict):
209218
continue
210219
if _card_matches(card, entity_id, card_type, heading):
211-
matches.append({
212-
"view_index": view_idx,
213-
"section_index": None,
214-
"card_index": card_idx,
215-
"jq_path": f".views[{view_idx}].cards[{card_idx}]",
216-
"card_type": card.get("type"),
217-
"card_config": card,
218-
})
220+
matches.append(
221+
{
222+
"view_index": view_idx,
223+
"section_index": None,
224+
"card_index": card_idx,
225+
"jq_path": f".views[{view_idx}].cards[{card_idx}]",
226+
"card_type": card.get("type"),
227+
"card_config": card,
228+
}
229+
)
219230

220231
return matches
221232

@@ -239,8 +250,7 @@ def _card_matches(
239250
card_entities = card.get("entities", [])
240251
if isinstance(card_entities, list):
241252
all_entities = [card_entity] + [
242-
e.get("entity", e) if isinstance(e, dict) else e
243-
for e in card_entities
253+
e.get("entity", e) if isinstance(e, dict) else e for e in card_entities
244254
]
245255
else:
246256
all_entities = [card_entity]
@@ -362,7 +372,9 @@ async def ha_config_get_dashboard(
362372
config = response.get("result") if isinstance(response, dict) else response
363373

364374
# Compute hash for optimistic locking in subsequent operations
365-
config_hash = _compute_config_hash(config) if isinstance(config, dict) else None
375+
config_hash = (
376+
_compute_config_hash(config) if isinstance(config, dict) else None
377+
)
366378

367379
# Calculate config size for progressive disclosure hint
368380
config_size = len(json.dumps(config)) if isinstance(config, dict) else 0
@@ -411,8 +423,9 @@ async def ha_config_set_dashboard(
411423
url_path: Annotated[
412424
str,
413425
Field(
414-
description="Unique URL path for dashboard (must contain hyphen, "
415-
"e.g., 'my-dashboard', 'mobile-view')"
426+
description="Dashboard URL path (e.g., 'my-dashboard'). "
427+
"Use 'default' or 'lovelace' for the default dashboard. "
428+
"New dashboards must use a hyphenated path."
416429
),
417430
],
418431
config: Annotated[
@@ -430,7 +443,7 @@ async def ha_config_set_dashboard(
430443
description="jq expression to transform existing dashboard config. "
431444
"Mutually exclusive with config and python_transform. Requires config_hash for validation. "
432445
"Examples: '.views[0].sections[1].cards[0].icon = \"mdi:thermometer\"', "
433-
"'.views[0].cards += [{\"type\": \"button\", \"entity\": \"light.bedroom\"}]', "
446+
'\'.views[0].cards += [{"type": "button", "entity": "light.bedroom"}]\', '
434447
"'del(.views[0].sections[0].cards[2])'. "
435448
"MULTI-OP: Chain with '|': 'del(.views[0].cards[2]) | .views[0].cards[0].icon = \"mdi:new\"'. "
436449
"Use ha_dashboard_find_card() to get jq_path for targeted edits."
@@ -447,8 +460,7 @@ async def ha_config_set_dashboard(
447460
"Simple: python_transform=\"config['views'][0]['cards'][0]['icon'] = 'mdi:lamp'\" "
448461
"Pattern: python_transform=\"for card in config['views'][0]['cards']: if 'light' in card.get('entity', ''): card['icon'] = 'mdi:lightbulb'\" "
449462
"Multi-op: python_transform=\"config['views'][0]['cards'][0]['icon'] = 'mdi:lamp'; del config['views'][0]['cards'][2]\" "
450-
"\n\n"
451-
+ get_security_documentation(),
463+
"\n\n" + get_security_documentation(),
452464
),
453465
] = None,
454466
config_hash: Annotated[
@@ -483,7 +495,8 @@ async def ha_config_set_dashboard(
483495
Creates a new dashboard or updates an existing one with the provided configuration.
484496
Supports three modes: full config replacement, Python transformation, OR jq-based transformation.
485497
486-
IMPORTANT: url_path must contain a hyphen (-) to be valid.
498+
Use 'default' or 'lovelace' to target the built-in default dashboard.
499+
New dashboards require a hyphenated url_path (e.g., 'my-dashboard').
487500
488501
WHEN TO USE WHICH MODE:
489502
- python_transform: RECOMMENDED for edits. Surgical/pattern-based updates, works on all platforms.
@@ -601,15 +614,22 @@ async def ha_config_set_dashboard(
601614
(title, icon), use ha_config_update_dashboard_metadata().
602615
"""
603616
try:
604-
# Validate url_path contains hyphen
605-
if "-" not in url_path:
617+
# Handle "default" as alias for the default dashboard
618+
# (matches ha_config_get_dashboard behavior)
619+
if url_path == "default":
620+
url_path = "lovelace"
621+
622+
# Validate url_path contains hyphen for new dashboards
623+
# The built-in "lovelace" dashboard is exempt since it already exists
624+
if "-" not in url_path and url_path != "lovelace":
606625
return {
607626
"success": False,
608627
"action": "set",
609628
"error": "url_path must contain a hyphen (-)",
610629
"suggestions": [
611630
f"Try '{url_path.replace('_', '-')}' instead",
612631
"Use format like 'my-dashboard' or 'mobile-view'",
632+
"Use 'lovelace' or 'default' to edit the default dashboard",
613633
],
614634
}
615635

@@ -728,7 +748,9 @@ async def ha_config_set_dashboard(
728748

729749
save_result = await client.send_websocket_message(save_data)
730750

731-
if isinstance(save_result, dict) and not save_result.get("success", True):
751+
if isinstance(save_result, dict) and not save_result.get(
752+
"success", True
753+
):
732754
error_msg = save_result.get("error", {})
733755
if isinstance(error_msg, dict):
734756
error_msg = error_msg.get("message", str(error_msg))
@@ -793,14 +815,18 @@ async def ha_config_set_dashboard(
793815
],
794816
}
795817

796-
current_config = response.get("result") if isinstance(response, dict) else response
818+
current_config = (
819+
response.get("result") if isinstance(response, dict) else response
820+
)
797821
if not isinstance(current_config, dict):
798822
return {
799823
"success": False,
800824
"action": "jq_transform",
801825
"url_path": url_path,
802826
"error": "Current dashboard config is invalid",
803-
"suggestions": ["Initialize dashboard with 'config' parameter first"],
827+
"suggestions": [
828+
"Initialize dashboard with 'config' parameter first"
829+
],
804830
}
805831

806832
# Validate config_hash for optimistic locking
@@ -819,7 +845,9 @@ async def ha_config_set_dashboard(
819845
}
820846

821847
# Apply jq transformation
822-
transformed_config, error = _apply_jq_transform(current_config, jq_transform)
848+
transformed_config, error = _apply_jq_transform(
849+
current_config, jq_transform
850+
)
823851
if error:
824852
return {
825853
"success": False,
@@ -843,7 +871,9 @@ async def ha_config_set_dashboard(
843871

844872
save_result = await client.send_websocket_message(save_data)
845873

846-
if isinstance(save_result, dict) and not save_result.get("success", True):
874+
if isinstance(save_result, dict) and not save_result.get(
875+
"success", True
876+
):
847877
error_msg = save_result.get("error", {})
848878
if isinstance(error_msg, dict):
849879
error_msg = error_msg.get("message", str(error_msg))
@@ -860,7 +890,9 @@ async def ha_config_set_dashboard(
860890

861891
# Compute new hash for potential chaining
862892
# transformed_config is guaranteed to be a dict here (validated above)
863-
new_config_hash = _compute_config_hash(cast(dict[str, Any], transformed_config))
893+
new_config_hash = _compute_config_hash(
894+
cast(dict[str, Any], transformed_config)
895+
)
864896

865897
return {
866898
"success": True,
@@ -885,6 +917,11 @@ async def ha_config_set_dashboard(
885917
d.get("url_path") == url_path for d in existing_dashboards
886918
)
887919

920+
# The built-in default dashboard ("lovelace") is always present
921+
# but isn't listed by lovelace/dashboards/list on fresh installs
922+
if url_path == "lovelace":
923+
dashboard_exists = True
924+
888925
# If dashboard doesn't exist, create it
889926
dashboard_id = None
890927
if not dashboard_exists:
@@ -950,7 +987,10 @@ async def ha_config_set_dashboard(
950987
# For existing dashboards, optionally validate config_hash and warn on large replacement
951988
if dashboard_exists:
952989
# Fetch current config for validation/comparison
953-
get_data: dict[str, Any] = {"type": "lovelace/config", "force": True}
990+
get_data: dict[str, Any] = {
991+
"type": "lovelace/config",
992+
"force": True,
993+
}
954994
if url_path:
955995
get_data["url_path"] = url_path
956996
current_response = await client.send_websocket_message(get_data)
@@ -1039,7 +1079,7 @@ async def ha_config_set_dashboard(
10391079
"error": str(e),
10401080
"suggestions": [
10411081
"Ensure url_path is unique (not already in use for different dashboard type)",
1042-
"Verify url_path contains a hyphen",
1082+
"New dashboards require a hyphenated url_path",
10431083
"Check that you have admin permissions",
10441084
"Verify config format is valid Lovelace JSON",
10451085
],
@@ -1446,7 +1486,6 @@ async def ha_get_card_documentation(
14461486
"error": str(e),
14471487
}
14481488

1449-
14501489
# =========================================================================
14511490
# Dashboard Resource Management Tools
14521491
# =========================================================================
@@ -1483,7 +1522,9 @@ async def ha_get_card_documentation(
14831522
async def ha_dashboard_find_card(
14841523
url_path: Annotated[
14851524
str | None,
1486-
Field(description="Dashboard URL path, e.g. 'lovelace-home'. Omit for default."),
1525+
Field(
1526+
description="Dashboard URL path, e.g. 'lovelace-home'. Omit for default."
1527+
),
14871528
] = None,
14881529
entity_id: Annotated[
14891530
str | None,
@@ -1505,7 +1546,9 @@ async def ha_dashboard_find_card(
15051546
] = None,
15061547
include_config: Annotated[
15071548
bool,
1508-
Field(description="Include full card configuration in results (increases output size)."),
1549+
Field(
1550+
description="Include full card configuration in results (increases output size)."
1551+
),
15091552
] = False,
15101553
) -> dict[str, Any]:
15111554
"""
@@ -1590,7 +1633,9 @@ async def ha_dashboard_find_card(
15901633
"action": "find_card",
15911634
"url_path": url_path,
15921635
"error": "Dashboard config is empty or invalid",
1593-
"suggestions": ["Initialize dashboard with ha_config_set_dashboard"],
1636+
"suggestions": [
1637+
"Initialize dashboard with ha_config_set_dashboard"
1638+
],
15941639
}
15951640

15961641
# Check for strategy dashboard
@@ -1630,7 +1675,8 @@ async def ha_dashboard_find_card(
16301675
"matches": matches,
16311676
"match_count": len(matches),
16321677
"hint": "Use jq_path with ha_config_set_dashboard(jq_transform=...) for targeted updates"
1633-
if matches else "No matches found. Try broader search criteria.",
1678+
if matches
1679+
else "No matches found. Try broader search criteria.",
16341680
}
16351681

16361682
except asyncio.CancelledError:
@@ -1653,4 +1699,3 @@ async def ha_dashboard_find_card(
16531699
"Verify dashboard with ha_config_get_dashboard(list_only=True)",
16541700
],
16551701
}
1656-

0 commit comments

Comments
 (0)