You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Home-AssistantConfig/tools/validate_dashboards.py

201 lines
7.6 KiB

#!/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())

Powered by TurnKey Linux.