parent
a9f1314026
commit
b36580b62f
@ -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…
Reference in new issue