Dashboards: HA 2026.8 Lovelace wiring + validation

pull/1581/head
Carlo Costanzo 2 months ago
parent a9f1314026
commit b36580b62f

@ -35,10 +35,10 @@ lovelace:
lovelace:
mode: yaml
title: "Overview"
icon: mdi:home
icon: mdi:view-dashboard
show_in_sidebar: true
require_admin: false
filename: dashboards/overview/dashboard.yaml
filename: ui-lovelace.yaml
dashboard-infrastructure:
mode: yaml
title: "Infrastructure"

@ -46,7 +46,12 @@ This folder holds YAML-managed Home Assistant Lovelace dashboards and UI resourc
This folder is referenced from `config/configuration.yaml` via:
- `lovelace.resource_mode: yaml`
- `lovelace.resources: !include dashboards/resources.yaml`
- `lovelace.dashboards: ... filename: dashboards/<dashboard>/dashboard.yaml`
- `lovelace.dashboards: ...`
- Default Overview YAML dashboard: `lovelace.dashboards.lovelace.filename: ui-lovelace.yaml`
- Additional YAML dashboards: `filename: dashboards/<dashboard>/dashboard.yaml`
Note:
- Do not use legacy `lovelace.mode: yaml` (removed in Home Assistant 2026.8).
Lovelace resources are loaded from:
- `config/dashboards/resources.yaml` (referenced by `lovelace.resources`)

@ -1,9 +1,9 @@
# Dashboards Scratchpad
## Current Working Items
Current working items:
- Keep YAML dashboards rendering (no missing includes/resources).
- Validate dashboards before restarting Home Assistant.
- (empty)
Patterns/notes:
- If a view looks like a centered skinny column, avoid `max_columns: 1`. Prefer `max_columns: 4` with a single section/card using `columns: 1`.
## Patterns / Notes
- (empty)

@ -378,7 +378,7 @@
entity: camera.birdseye
tap_action:
action: navigate
navigation_path: /lovelace/camera
navigation_path: /lovelace/cameras
- type: vertical-stack
cards:
- type: weather-forecast

@ -35,10 +35,14 @@ function Get-EffectiveUrlAndStatus {
$targets = @(
'/',
'/lovelace',
'/lovelace/cameras',
'/profile',
'/dashboard-infrastructure',
'/dashboard-infrastructure/home',
'/dashboard-infrastructure/activity',
'/dashboard-infrastructure/docker',
'/dashboard-infrastructure/mariadb',
'/dashboard-infrastructure/vacuum',
'/dashboard-kiosk'
)
@ -47,7 +51,7 @@ foreach ($path in $targets) {
$url = "$BaseUrl$path"
$r = Get-EffectiveUrlAndStatus -Url $url
$isLogin = $r.EffectiveUrl -match '/(login|auth/authorize)\b'
$isLogin = $r.EffectiveUrl -match '/(login|auth/authorize)\\b'
$statusOk = $r.StatusCode -ge 200 -and $r.StatusCode -lt 400
if ($isLogin -or -not $statusOk) {

@ -0,0 +1,13 @@
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$py = Get-Command py -ErrorAction SilentlyContinue
if (-not $py) {
throw "Python launcher 'py' not found. Install Python 3 or run tools/validate_dashboards.py with your python."
}
py -3 "$repoRoot\tools\validate_dashboards.py"

@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Fast local validation for YAML-managed Lovelace dashboards.
Goals:
- Catch missing include targets (most common cause of "Unknown error" in UI).
- Catch missing dashboard entrypoints referenced by configuration.yaml.
- Encourage a repeatable workflow before restarting Home Assistant.
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
RE_INCLUDE = re.compile(r"!\s*(include(?:_dir_(?:list|merge_list|named|merge_named))?)\s+([^\s#]+)")
RE_TOP_LEVEL_KEY = re.compile(r"^[A-Za-z0-9_]+:\s*$")
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def to_local_path(root: Path, config_path: str) -> Path:
# Lovelace includes in this repo should use /config/... paths.
if config_path.startswith("/config/"):
rel = config_path[len("/config/") :]
return root / "config" / rel
# Allow relative includes (rare). Resolve from repo root.
return (root / config_path).resolve()
def iter_yaml_files(root: Path) -> list[Path]:
return sorted((root / "config" / "dashboards").rglob("*.yaml"))
def extract_top_level_block(text: str, key: str) -> str | None:
lines = text.splitlines()
start = None
for i, line in enumerate(lines):
if re.match(rf"^{re.escape(key)}:\s*$", line):
start = i
break
if start is None:
return None
end = len(lines)
for j in range(start + 1, len(lines)):
line = lines[j]
if not line or line.lstrip().startswith("#"):
continue
if line.startswith(" "):
continue
if RE_TOP_LEVEL_KEY.match(line):
end = j
break
# Return block content excluding the "<key>:" line itself.
return "\n".join(lines[start + 1 : end]) + "\n"
def validate_headers(yaml_files: list[Path]) -> list[str]:
errs: list[str] = []
for p in yaml_files:
try:
head = p.read_text(encoding="utf-8", errors="replace").splitlines()[:3]
except OSError as e:
errs.append(f"ERROR: cannot read {p}: {e}")
continue
joined = "\n".join(head)
if "######################################################################" not in joined:
errs.append(f"ERROR: missing @CCOSTAN header block: {p}")
return errs
def validate_includes(root: Path, yaml_files: list[Path]) -> list[str]:
errs: list[str] = []
for p in yaml_files:
try:
text = p.read_text(encoding="utf-8", errors="replace")
except OSError as e:
errs.append(f"ERROR: cannot read {p}: {e}")
continue
for m in RE_INCLUDE.finditer(text):
tag = m.group(1)
target = m.group(2).strip().strip("'\"")
local = to_local_path(root, target)
if tag.startswith("include_dir_"):
if not local.exists():
errs.append(f"ERROR: include dir missing ({tag}): {p} -> {target}")
elif not local.is_dir():
errs.append(f"ERROR: include dir is not a directory ({tag}): {p} -> {target}")
else:
# For include_dir_list, ensure there is at least one yaml.
if tag == "include_dir_list":
found = list(Path(local).glob("*.yaml"))
if not found:
errs.append(f"ERROR: include_dir_list empty: {p} -> {target}")
else:
if not local.exists():
errs.append(f"ERROR: include file missing ({tag}): {p} -> {target}")
elif not local.is_file():
errs.append(f"ERROR: include file is not a file ({tag}): {p} -> {target}")
return errs
def validate_configuration_wiring(root: Path) -> list[str]:
errs: list[str] = []
cfg = root / "config" / "configuration.yaml"
if not cfg.exists():
return [f"ERROR: missing {cfg}"]
text = cfg.read_text(encoding="utf-8", errors="replace")
if "\nlovelace:" not in text:
errs.append("ERROR: config/configuration.yaml missing `lovelace:` block (YAML dashboards wiring).")
lovelace_block = extract_top_level_block(text, "lovelace")
if lovelace_block is None:
errs.append("ERROR: config/configuration.yaml missing `lovelace:` block (YAML dashboards wiring).")
return errs
# Legacy (pre-2026.8) style was `lovelace: { mode: yaml }`. This must not exist.
if re.search(r"(?m)^\s{2}mode:\s*yaml\s*$", lovelace_block):
errs.append(
"ERROR: config/configuration.yaml uses legacy `lovelace.mode: yaml` (removed in HA 2026.8). "
"Define the YAML Overview as a `lovelace.dashboards.lovelace` entry instead."
)
# New (2026.8+) style: YAML Overview is declared as a dashboard pointing at ui-lovelace.yaml.
ll_lines = lovelace_block.splitlines()
dash_i = next((i for i, l in enumerate(ll_lines) if re.match(r"^\s{2}dashboards:\s*$", l)), None)
if dash_i is None:
errs.append("ERROR: config/configuration.yaml missing `lovelace.dashboards:` block.")
else:
lov_i = next((i for i in range(dash_i + 1, len(ll_lines)) if re.match(r"^\s{4}lovelace:\s*$", ll_lines[i])), None)
if lov_i is None:
errs.append("ERROR: config/configuration.yaml missing `lovelace.dashboards.lovelace:` entry.")
else:
entry: list[str] = []
for j in range(lov_i + 1, len(ll_lines)):
line = ll_lines[j]
if not line or line.lstrip().startswith("#"):
entry.append(line)
continue
# Next dashboard entry (same indent) or end of dashboards block.
if re.match(r"^\s{4}[A-Za-z0-9_-]+:\s*$", line):
break
if re.match(r"^\s{2}[A-Za-z0-9_-]+:\s*$", line):
break
entry.append(line)
entry_text = "\n".join(entry) + "\n"
if not re.search(r"(?m)^\s{6}mode:\s*yaml\s*$", entry_text):
errs.append("ERROR: config/configuration.yaml missing `lovelace.dashboards.lovelace.mode: yaml`.")
if not re.search(r"(?m)^\s{6}filename:\s*ui-lovelace\.yaml\s*$", entry_text):
errs.append("ERROR: config/configuration.yaml missing `lovelace.dashboards.lovelace.filename: ui-lovelace.yaml`.")
ui = root / "config" / "ui-lovelace.yaml"
if not ui.exists():
errs.append("ERROR: missing config/ui-lovelace.yaml (YAML Overview entrypoint).")
required_paths = [
root / "config" / "dashboards" / "resources.yaml",
root / "config" / "dashboards" / "overview" / "dashboard.yaml",
root / "config" / "dashboards" / "infrastructure" / "dashboard.yaml",
root / "config" / "dashboards" / "kiosk" / "dashboard.yaml",
]
for rp in required_paths:
if not rp.exists():
errs.append(f"ERROR: missing required dashboard file: {rp}")
return errs
def main() -> int:
root = repo_root()
dashboards_dir = root / "config" / "dashboards"
if not dashboards_dir.exists():
print(f"ERROR: missing dashboards dir: {dashboards_dir}")
return 2
yaml_files = iter_yaml_files(root)
errs: list[str] = []
errs.extend(validate_configuration_wiring(root))
errs.extend(validate_headers(yaml_files))
errs.extend(validate_includes(root, yaml_files))
if errs:
for e in errs:
print(e)
print(f"FAIL ({len(errs)} issues)")
return 2
print(f"OK: validated {len(yaml_files)} dashboard YAML files")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Loading…
Cancel
Save

Powered by TurnKey Linux.