From 699e03a22a0da0573f454f9d95106ff41e7af681 Mon Sep 17 00:00:00 2001 From: Carlo Costanzo Date: Mon, 9 Feb 2026 00:13:59 -0500 Subject: [PATCH] Dashboards: HA 2026.8 Lovelace wiring + validation --- config/configuration.yaml | 4 +- config/dashboards/README.md | 7 +- config/dashboards/SCRATCHPAD.md | 10 +- .../overview/partials/home_sections.yaml | 2 +- tools/ha_ui_smoke.ps1 | 8 +- tools/validate_dashboards.ps1 | 13 ++ tools/validate_dashboards.py | 200 ++++++++++++++++++ 7 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 tools/validate_dashboards.ps1 create mode 100644 tools/validate_dashboards.py diff --git a/config/configuration.yaml b/config/configuration.yaml index 23203d8c..197e2508 100755 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -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" diff --git a/config/dashboards/README.md b/config/dashboards/README.md index 19b964b1..a01c6946 100644 --- a/config/dashboards/README.md +++ b/config/dashboards/README.md @@ -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.yaml` +- `lovelace.dashboards: ...` + - Default Overview YAML dashboard: `lovelace.dashboards.lovelace.filename: ui-lovelace.yaml` + - Additional YAML dashboards: `filename: dashboards//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`) diff --git a/config/dashboards/SCRATCHPAD.md b/config/dashboards/SCRATCHPAD.md index d4696573..a007a8a9 100644 --- a/config/dashboards/SCRATCHPAD.md +++ b/config/dashboards/SCRATCHPAD.md @@ -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) diff --git a/config/dashboards/overview/partials/home_sections.yaml b/config/dashboards/overview/partials/home_sections.yaml index b4673fcb..c52b8a51 100644 --- a/config/dashboards/overview/partials/home_sections.yaml +++ b/config/dashboards/overview/partials/home_sections.yaml @@ -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 diff --git a/tools/ha_ui_smoke.ps1 b/tools/ha_ui_smoke.ps1 index b1a11505..0f897f47 100644 --- a/tools/ha_ui_smoke.ps1 +++ b/tools/ha_ui_smoke.ps1 @@ -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) { diff --git a/tools/validate_dashboards.ps1 b/tools/validate_dashboards.ps1 new file mode 100644 index 00000000..d39f0b7b --- /dev/null +++ b/tools/validate_dashboards.ps1 @@ -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" + diff --git a/tools/validate_dashboards.py b/tools/validate_dashboards.py new file mode 100644 index 00000000..9b5853b1 --- /dev/null +++ b/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 ":" 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())