mirror of
https://github.com/CCOSTAN/Home-AssistantConfig.git
synced 2026-07-23 15:56:09 -07:00
Dashboards: HA 2026.8 Lovelace wiring + validation
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user