Refine dashboard validation and UI smoke checks

pull/1898/head
Carlo Costanzo 4 months ago
parent 0a179c7fa6
commit dc8a4897c6

@ -1,22 +0,0 @@
######################################################################
# @CCOSTAN - Follow Me on X
# For more info visit https://www.vcloudinfo.com/click-here
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
# Infrastructure Partial - Activity sections
# Activity feed wrapped in the standard Infrastructure layout.
# -------------------------------------------------------------------
# Notes: Full-width logbook view targeted at `sensor.activity_feed`.
######################################################################
- type: grid
column_span: 4
columns: 1
square: false
cards:
- type: logbook
target:
entity_id:
- sensor.activity_feed
grid_options:
columns: full

@ -4,11 +4,12 @@
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
# Systems Partial - operations summary
# House-system health strip, active issues, datacenter, and network trends.
# House-system health strip, active issues, and datacenter status.
# -------------------------------------------------------------------
# Notes: The health ribbon mirrors the visible dashboard navigation order and labels.
# Notes: DNS and external-monitoring faults roll up under Network instead of duplicating it.
# Notes: All overview trends use a 24-hour window with visible series labels.
# Notes: Network trend panels live on Network so Overview stays focused on broad house status.
# Notes: Power & Solar shows realtime flow; accumulated history remains in Energy.
######################################################################
# -------------------------------------------------------------------
@ -24,7 +25,7 @@
columns: full
layout_type: custom:grid-layout
layout:
grid-template-columns: repeat(8, minmax(0, 1fr))
grid-template-columns: repeat(9, minmax(0, 1fr))
grid-auto-flow: row
grid-auto-rows: min-content
grid-gap: 12px
@ -101,6 +102,18 @@
action: navigate
navigation_path: /dashboard-infrastructure/network
- type: custom:button-card
template: bearstone_infra_status_kpi
entity: binary_sensor.powerwall_status
name: Powerwall
icon: mdi:home-battery-outline
variables:
ok_state: 'on'
healthy_text: ONLINE
alert_text: OFFLINE
tap_action:
action: more-info
- type: custom:button-card
template: bearstone_infra_status_kpi
entity: sensor.mariadb_status
@ -138,7 +151,7 @@
columns: full
layout_type: custom:grid-layout
layout:
grid-template-columns: repeat(2, minmax(0, 1fr))
grid-template-columns: repeat(3, minmax(0, 1fr))
grid-auto-flow: row
grid-auto-rows: min-content
grid-gap: 12px
@ -294,60 +307,6 @@
action: navigate
navigation_path: /dashboard-infrastructure/proxmox
- type: custom:button-card
template: bearstone_infra_panel_header
name: Network Trends
- type: custom:mini-graph-card
name: Internet Throughput
icon: mdi:speedometer
hours_to_show: 24
points_per_hour: 1
line_width: 2
smoothing: true
show:
fill: false
legend: true
labels: false
name: true
icon: true
state: false
entities:
- entity: sensor.speedtest_download
name: Download
show_legend_state: true
- entity: sensor.speedtest_upload
name: Upload
show_legend_state: true
card_mod:
style: !include /config/dashboards/infrastructure/card_mod/infra_card.yaml
- type: custom:mini-graph-card
name: Wi-Fi Clients
icon: mdi:wifi
hours_to_show: 24
points_per_hour: 1
line_width: 2
smoothing: true
show:
legend: true
labels: false
name: true
icon: true
state: true
entities:
- entity: sensor.total_wifi_clients
name: Total
show_graph: false
- entity: sensor.unifi_ap_office_clients
name: Office
- entity: sensor.unifi_ap_study_clients
name: Study
- entity: sensor.unifi_ap_garage_clients
name: Garage
card_mod:
style: !include /config/dashboards/infrastructure/card_mod/infra_card.yaml
- type: custom:vertical-stack-in-card
card_mod:
style: !include /config/dashboards/infrastructure/card_mod/infra_panel.yaml
@ -423,3 +382,42 @@
const status = String(entity?.state ?? '').toUpperCase();
return status ? (status.includes('OB') ? 'YES' : 'NO') : 'NO DATA';
]]]
- type: custom:vertical-stack-in-card
card_mod:
style: !include /config/dashboards/infrastructure/card_mod/infra_panel.yaml
cards:
- type: custom:button-card
template: bearstone_infra_panel_header
name: Power & Solar
- type: custom:power-flow-card-plus
dashboard_link: /energy/overview
dashboard_link_label: Open Energy
clickable_entities: false
use_new_flow_rate_model: true
power_outage:
entity: binary_sensor.powerwall_grid_status
state_alert: 'off'
icon_alert: mdi:transmission-tower-off
label_alert: GRID OUTAGE
entities:
grid:
entity: sensor.powerwall_site_power
name: Grid
solar:
entity: sensor.powerwall_solar_power
name: Solar
color: '#f9a825'
color_icon: true
color_value: true
battery:
entity: sensor.powerwall_battery_power
name: Battery
state_of_charge: sensor.powerwall_charge
home:
entity: sensor.powerwall_load_power
name: Home
tap_action:
action: navigate
navigation_path: /energy/overview

@ -1,35 +0,0 @@
######################################################################
# @CCOSTAN - Follow Me on X
# For more info visit https://www.vcloudinfo.com/click-here
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
# Infrastructure Partial - Top status chips (Home)
# Home-only chips row (no Pi-hole control; DNS health is alert-only on Home).
# -------------------------------------------------------------------
# Notes: Keep this lightweight; it should render fast on the Home view.
######################################################################
type: grid
column_span: 4
columns: 3
square: false
cards:
- type: custom:button-card
template: bearstone_infra_chip
entity: binary_sensor.node_proxmox1_updates_packages
name: Proxmox01
icon: mdi:server
state_display: >
[[[ return entity.state === 'on' ? 'Updates pending' : 'Up to date'; ]]]
- type: custom:button-card
template: bearstone_infra_chip
entity: binary_sensor.node_proxmox02_updates_packages
name: Proxmox02
icon: mdi:server
state_display: >
[[[ return entity.state === 'on' ? 'Updates pending' : 'Up to date'; ]]]
- type: custom:button-card
template: bearstone_infra_chip
entity: sensor.garage_ups_status
name: Garage UPS
icon: mdi:transmission-tower

@ -1,41 +0,0 @@
######################################################################
# @CCOSTAN - Follow Me on X
# For more info visit https://www.vcloudinfo.com/click-here
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
# Infrastructure Partial - Top status chips (shared)
# Common "chips row" used across Infrastructure views.
# -------------------------------------------------------------------
# Notes: Keep this lightweight; it should render fast on every view.
######################################################################
type: grid
column_span: 4
columns: 4
square: false
cards:
- type: custom:button-card
template: bearstone_infra_chip
entity: binary_sensor.node_proxmox1_updates_packages
name: Proxmox01
icon: mdi:server
state_display: >
[[[ return entity.state === 'on' ? 'Updates pending' : 'Up to date'; ]]]
- type: custom:button-card
template: bearstone_infra_chip
entity: binary_sensor.node_proxmox02_updates_packages
name: Proxmox02
icon: mdi:server
state_display: >
[[[ return entity.state === 'on' ? 'Updates pending' : 'Up to date'; ]]]
- type: custom:button-card
template: bearstone_infra_chip
entity: sensor.garage_ups_status
name: Garage UPS
icon: mdi:transmission-tower
- type: custom:button-card
template: bearstone_infra_chip
entity: sensor.vcloudinfo_com
name: vCloudinfo
icon: mdi:microsoft-internet-explorer

@ -72,7 +72,7 @@
navigation_path: /dashboard-infrastructure/pihole
# -------------------------------------------------------------------
# Trends, Pi-hole controls, and website health
# Trends, Wi-Fi, Pi-hole controls, and website health
# -------------------------------------------------------------------
- type: grid
column_span: 4
@ -142,6 +142,32 @@
action: navigate
navigation_path: /dashboard-infrastructure/wan
- type: custom:mini-graph-card
name: Wi-Fi Clients
icon: mdi:wifi
hours_to_show: 24
points_per_hour: 1
line_width: 2
smoothing: true
show:
legend: true
labels: false
name: true
icon: true
state: true
entities:
- entity: sensor.total_wifi_clients
name: Total
show_graph: false
- entity: sensor.unifi_ap_office_clients
name: Office
- entity: sensor.unifi_ap_study_clients
name: Study
- entity: sensor.unifi_ap_garage_clients
name: Garage
card_mod:
style: !include /config/dashboards/infrastructure/card_mod/infra_card.yaml
- type: custom:vertical-stack-in-card
card_mod:
style: !include /config/dashboards/infrastructure/card_mod/infra_panel.yaml

@ -1,37 +0,0 @@
######################################################################
# @CCOSTAN - Follow Me on X
# For more info visit https://www.vcloudinfo.com/click-here
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
# Infrastructure Partial - proxmox badges
# Reusable list extracted from a view for smaller diffs and safer edits.
# -------------------------------------------------------------------
# Notes: Extracted from config/dashboards/infrastructure/views/01_proxmox.yaml key `badges`.
######################################################################
- type: entity
show_name: true
show_state: true
show_icon: true
entity: binary_sensor.node_proxmox1_updates_packages
name: ProxMox1
- type: entity
show_name: true
show_state: true
show_icon: true
entity: binary_sensor.node_proxmox02_updates_packages
name: Proxmox02
- type: entity
icon: mdi:transmission-tower
show_name: true
show_state: true
show_icon: true
entity: sensor.garage_ups_status
name: Garage UPS
- type: entity
show_name: true
show_state: true
show_icon: true
icon: mdi:microsoft-internet-explorer
entity: sensor.vcloudinfo_com
name: vCloudinfo

@ -62,6 +62,10 @@
ok_state: 'off'
healthy_text: CURRENT
alert_text: PENDING
pending_detail: true
pending_count_entity: sensor.node_proxmox1_total_updates
pending_schedule_hour: 2
pending_schedule_minute: 15
- type: custom:button-card
template: bearstone_infra_status_kpi
@ -72,6 +76,10 @@
ok_state: 'off'
healthy_text: CURRENT
alert_text: PENDING
pending_detail: true
pending_count_entity: sensor.node_proxmox02_total_updates
pending_schedule_hour: 2
pending_schedule_minute: 15
# -------------------------------------------------------------------
# Host resource trends

@ -1,15 +0,0 @@
######################################################################
# @CCOSTAN - Follow Me on X
# For more info visit https://www.vcloudinfo.com/click-here
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
# Infrastructure Partial - view cards
# Reusable list extracted from a view for smaller diffs and safer edits.
# -------------------------------------------------------------------
# Notes: Extracted from config/dashboards/infrastructure/views/04_view.yaml key `cards`.
######################################################################
- type: logbook
target:
entity_id:
- sensor.activity_feed

@ -853,6 +853,20 @@ bearstone_infra_status_kpi:
const unavailable = ['unknown', 'unavailable', 'none', ''].includes(raw);
const okState = String(variables?.ok_state ?? 'off').toLowerCase();
if (unavailable) return 'NO DATA';
if (variables?.pending_detail && raw === 'on') {
const countEntity = String(variables?.pending_count_entity ?? '').trim();
const countRaw = countEntity ? states[countEntity]?.state : '';
const count = Number.parseInt(countRaw, 10);
const countText = Number.isFinite(count) && count > 0
? `${count} PKG${count === 1 ? '' : 'S'}`
: 'UPDATES';
const next = new Date();
next.setHours(Number(variables?.pending_schedule_hour ?? 2), Number(variables?.pending_schedule_minute ?? 15), 0, 0);
if (next <= new Date()) next.setDate(next.getDate() + 1);
const dateText = next.toLocaleDateString(undefined, {month: 'short', day: 'numeric'}).toUpperCase();
const timeText = next.toLocaleTimeString(undefined, {hour: 'numeric', minute: '2-digit'});
return `${countText} · NEXT CHECK ${dateText} ${timeText}`;
}
if (raw === okState) return variables?.healthy_text ?? 'HEALTHY';
if (variables?.alert_text) return variables.alert_text;
if (variables?.raw_suffix) return `${entity.state} ${variables.raw_suffix}`;

@ -4,7 +4,7 @@
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
# Systems View - operations summary
# Compact house-system health, active exceptions, datacenter, and network trends.
# Compact house-system health, active exceptions, and datacenter status.
# -------------------------------------------------------------------
# Notes: Production Systems landing view; stable path remains `/dashboard-infrastructure/home`.
######################################################################

@ -72,40 +72,6 @@ sections:
action: none
layout: horizontal
fill_container: true
# Fallback card: Mushroom renders optional schedule details from the controller
# summary sensor so a missing native schedule switch does not break the view.
- type: custom:mushroom-template-card
primary: >-
{% set summary = state_attr('sensor.rachio_controller_status', 'schedule_summary') %}
{% set schedule_state = state_attr('sensor.rachio_active_zone', 'schedule_state') %}
{% if summary not in ['unknown', 'unavailable', 'none', '', none] %}
Schedule: {{ summary }}
{% elif schedule_state not in ['unknown', 'unavailable', 'none', '', none] %}
Schedule: {{ schedule_state | title }}
{% else %}
Schedule not exposed
{% endif %}
secondary: >-
{% set duration = state_attr('sensor.rachio_controller_status', 'schedule_duration') %}
{% set schedule_type = state_attr('sensor.rachio_controller_status', 'schedule_type') %}
{% if schedule_type not in ['unknown', 'unavailable', 'none', '', none] and duration not in ['unknown', 'unavailable', 'none', '', none] %}
{{ schedule_type }} - {{ duration }}
{% elif duration not in ['unknown', 'unavailable', 'none', '', none] %}
{{ duration }}
{% else %}
Controller schedule details are unavailable.
{% endif %}
icon: mdi:calendar-clock
entity: sensor.rachio_controller_status
icon_color: blue
tap_action:
action: more-info
hold_action:
action: none
double_tap_action:
action: none
layout: horizontal
fill_container: true
- type: tile
entity: switch.carlo_rachio_rain_delay
name: Rain delay

@ -0,0 +1,159 @@
from __future__ import annotations
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
TOOLS = Path(__file__).resolve().parents[1] / "tools"
sys.path.insert(0, str(TOOLS))
from validate_dashboards import ValidationError, validate_repository # noqa: E402
HEADER = """\
######################################################################
# @CCOSTAN - Follow Me on X
# For more info visit https://www.vcloudinfo.com/click-here
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
"""
class DashboardValidatorTests(unittest.TestCase):
def setUp(self) -> None:
self.tempdir = tempfile.TemporaryDirectory(prefix="dashboard-validator-")
self.root = Path(self.tempdir.name)
(self.root / "tools").mkdir()
(self.root / "config" / "packages").mkdir(parents=True)
(self.root / "config" / "dashboards" / "overview" / "views").mkdir(
parents=True
)
(self.root / "tools" / "dashboard_entity_allowlist.txt").write_text(
"# fixture allowlist\n", encoding="utf-8"
)
self._write(
"config/configuration.yaml",
"""
lovelace:
resource_mode: yaml
resources: !include dashboards/resources.yaml
dashboards:
lovelace:
mode: yaml
filename: ui-lovelace.yaml
""",
)
self._write(
"config/ui-lovelace.yaml",
"!include /config/dashboards/overview/dashboard.yaml\n",
dedent=False,
)
self._write("config/dashboards/resources.yaml", HEADER + "[]\n", dedent=False)
self._write(
"config/dashboards/overview/dashboard.yaml",
HEADER
+ "views: !include_dir_list /config/dashboards/overview/views\n",
dedent=False,
)
self._write(
"config/dashboards/overview/views/01_home.yaml",
HEADER
+ """\
title: Home
path: home
type: sections
sections:
- type: grid
cards:
- type: entity
entity: sensor.real
""",
dedent=False,
)
self._write(
"config/packages/entities.yaml",
"sensor.real\n",
dedent=False,
)
def tearDown(self) -> None:
self.tempdir.cleanup()
def _write(self, relative: str, content: str, *, dedent: bool = True) -> Path:
path = self.root / relative
path.parent.mkdir(parents=True, exist_ok=True)
value = textwrap.dedent(content).lstrip() if dedent else content
path.write_text(value, encoding="utf-8")
return path
def test_valid_fixture_builds_routes(self) -> None:
result = validate_repository(self.root)
self.assertEqual(result.routes, ("/lovelace", "/lovelace/home"))
self.assertIn("sensor.real", result.entity_references)
def test_malformed_dashboard_yaml_fails(self) -> None:
path = self.root / "config/dashboards/overview/dashboard.yaml"
path.write_text(path.read_text(encoding="utf-8") + "broken: [\n", encoding="utf-8")
with self.assertRaisesRegex(ValidationError, "invalid YAML"):
validate_repository(self.root)
def test_missing_entrypoint_include_fails(self) -> None:
self._write(
"config/ui-lovelace.yaml",
"!include /config/dashboards/overview/missing.yaml\n",
dedent=False,
)
with self.assertRaisesRegex(ValidationError, "missing include file"):
validate_repository(self.root)
def test_nonexistent_static_entity_fails(self) -> None:
path = self.root / "config/dashboards/overview/views/01_home.yaml"
path.write_text(
path.read_text(encoding="utf-8").replace("sensor.real", "sensor.missing"),
encoding="utf-8",
)
with self.assertRaisesRegex(ValidationError, "sensor.missing"):
validate_repository(self.root)
def test_commented_include_is_ignored(self) -> None:
path = self.root / "config/dashboards/overview/dashboard.yaml"
path.write_text(
path.read_text(encoding="utf-8")
+ "# ignored: !include /config/dashboards/overview/missing.yaml\n",
encoding="utf-8",
)
validate_repository(self.root)
def test_duplicate_view_path_fails(self) -> None:
self._write(
"config/dashboards/overview/views/02_duplicate.yaml",
HEADER + "title: Duplicate\npath: home\ntype: sections\nsections: []\n",
dedent=False,
)
with self.assertRaisesRegex(ValidationError, "duplicate view path"):
validate_repository(self.root)
def test_orphaned_dashboard_file_fails(self) -> None:
self._write(
"config/dashboards/overview/orphan.yaml",
HEADER + "type: entity\nentity: sensor.real\n",
dedent=False,
)
with self.assertRaisesRegex(ValidationError, "orphaned dashboard YAML"):
validate_repository(self.root)
def test_duplicate_yaml_key_fails(self) -> None:
path = self.root / "config/dashboards/overview/views/01_home.yaml"
path.write_text(
path.read_text(encoding="utf-8") + "path: duplicate\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ValidationError, "duplicate key"):
validate_repository(self.root)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,138 @@
# Static dashboard entities supplied by UI-managed helpers or integrations.
# Keep sorted. The validator proves these exist in live HA storage when available.
automation.llm_vision_garage_cans_upper_right
binary_sensor.garage_phyn_leak_alert
binary_sensor.joanna_qmd_healthy
binary_sensor.joanna_session_source_indexed
binary_sensor.mcu1_gpio10
binary_sensor.mcu1_gpio13
binary_sensor.mcu1_gpio14
binary_sensor.mcu1_gpio4
binary_sensor.mcu2_gpio10
binary_sensor.mcu2_gpio12
binary_sensor.mcu2_gpio13
binary_sensor.mcu2_gpio14
binary_sensor.mcu2_gpio4
binary_sensor.mcu2_gpio9
binary_sensor.mcu3_gpio10
binary_sensor.mcu3_gpio14
binary_sensor.mcu3_gpio4
binary_sensor.mcu3_gpio5
button.carlo_hass_prune_unused_volumes
button.docker17_prune_unused_volumes
button.docker2_prune_unused_volumes
button.docker69_prune_unused_volumes
button.l10s_vacuum_reset_detergent
button.l10s_vacuum_reset_filter
button.l10s_vacuum_reset_main_brush
button.l10s_vacuum_reset_mop_pad
button.l10s_vacuum_reset_side_brush
calendar.llm_vision_timeline
camera.alarm_panel_1
camera.kidsgate
number.sleepnumber_carlo_carlo_firmness
number.sleepnumber_carlo_left_head_position_2
number.sleepnumber_carlo_right_foot_position_2
number.sleepnumber_carlo_right_head_position_2
number.sleepnumber_carlo_stacey_firmness
scene.living_room_daytime_cool
scene.living_room_evening_amber
script.rachio_pause_watering_30
script.rachio_resume_watering
script.rachio_stop_watering
script.reset_downstairs_filter
script.reset_upstairs_filter
select.l10s_vacuum_auto_empty_frequency
select.l10s_vacuum_mop_pad_humidity
select.l10s_vacuum_suction_level
select.sleepnumber_carlo_foundation_preset_left
sensor.alarm_panel_1_battery
sensor.carlo_location_display
sensor.carlo_nas01_temperature
sensor.carlo_nas01_volume_1_used_space
sensor.carlo_nvr_temperature
sensor.carlo_nvr_volume_1_used_space
sensor.codex_reset_credit_status
sensor.database_oldest_record
sensor.database_records_per_day
sensor.database_size
sensor.database_tables_count
sensor.database_total_records
sensor.garage_ups_battery_charge
sensor.garage_ups_battery_runtime
sensor.joanna_all_dispatches_24h
sensor.joanna_all_dispatches_30d
sensor.joanna_all_dispatches_7d
sensor.joanna_ha_dispatches_24h
sensor.joanna_ha_dispatches_30d
sensor.joanna_ha_dispatches_7d
sensor.joanna_ha_errors_24h
sensor.joanna_ha_success_rate_24h
sensor.joanna_memory_docs
sensor.joanna_queue_pending
sensor.justin_location_display
sensor.l10s_vacuum_battery_level
sensor.l10s_vacuum_cleaning_count
sensor.l10s_vacuum_cleaning_history
sensor.l10s_vacuum_detergent_left
sensor.l10s_vacuum_dust_collection
sensor.l10s_vacuum_filter_left
sensor.l10s_vacuum_low_water_warning
sensor.l10s_vacuum_main_brush_left
sensor.l10s_vacuum_mop_pad_left
sensor.l10s_vacuum_side_brush_left
sensor.l10s_vacuum_total_cleaned_area
sensor.mariadb_buffer_pool_size
sensor.mariadb_buffer_sizes
sensor.mariadb_connections
sensor.mariadb_io_capacity
sensor.mariadb_io_threads
sensor.mariadb_log_file_size
sensor.mariadb_max_connections
sensor.mariadb_performance
sensor.mariadb_questions
sensor.mariadb_status
sensor.mariadb_table_cache
sensor.mariadb_tmp_table_size
sensor.mariadb_uptime
sensor.mariadb_version
sensor.node_proxmox02_cpu_used
sensor.node_proxmox02_memory_used_percentage
sensor.node_proxmox1_cpu_used
sensor.node_proxmox1_memory_used_percentage
sensor.number_of_lights_on
sensor.paige_location_display
sensor.phyn_average_water_flow_rate
sensor.phyn_average_water_temperature
sensor.qemu_carlo_hass_105_cpu_used
sensor.qemu_carlo_hass_105_memory_used_percentage
sensor.qemu_docker2_101_cpu_used
sensor.qemu_docker2_101_memory_used_percentage
sensor.qemu_docker69_169_cpu_used
sensor.qemu_docker69_169_memory_used_percentage
sensor.rachio_active_zone
sensor.rachio_backyard_shrubs_last_run
sensor.rachio_controller_status
sensor.rachio_front_hedges_last_run
sensor.rachio_front_lawn_last_run
sensor.rachio_last_run_summary
sensor.rachio_left_side_house_last_run
sensor.sleepnumber_carlo_carlo_heart_rate_average
sensor.sleepnumber_carlo_carlo_heart_rate_variability
sensor.sleepnumber_carlo_carlo_respiratory_rate_average
sensor.sleepnumber_carlo_carlo_sleep_duration
sensor.sleepnumber_carlo_stacey_heart_rate_average
sensor.sleepnumber_carlo_stacey_heart_rate_variability
sensor.sleepnumber_carlo_stacey_respiratory_rate_average
sensor.sleepnumber_carlo_stacey_sleep_duration
sensor.speedtest_ping
sensor.stacey_location_display
sensor.total_wifi_clients
sensor.water_softener_salt_average_days_between_refills
sensor.water_softener_salt_refill_count
sensor.water_softener_salt_total_added
switch.l10s_vacuum_auto_dust_collecting
switch.phyn_autoshutoff_enabled
switch.phyn_scheduled_leak_test_enabled
update.llm_vision_update
weather.pirateweather

@ -0,0 +1,294 @@
#!/usr/bin/env node
"use strict";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
require("node:module").Module._initPaths();
function parseArguments(argv) {
const values = {
baseUrl: process.env.HASS_PLAYWRIGHT_BASE_URL || "http://192.168.10.10:8123",
outputDir: process.env.HASS_PLAYWRIGHT_OUTPUT_DIR || "output/playwright/ha-ui-smoke",
routesJson: process.env.HASS_UI_ROUTES_JSON || "[]",
headed: false,
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--headed") {
values.headed = true;
continue;
}
const next = argv[index + 1];
if (!next) {
throw new Error(`Missing value for ${argument}`);
}
if (argument === "--base-url") {
values.baseUrl = next;
} else if (argument === "--output-dir") {
values.outputDir = next;
} else if (argument === "--routes-json") {
values.routesJson = next;
} else {
throw new Error(`Unknown argument ${argument}`);
}
index += 1;
}
return values;
}
function loadPlaywright() {
const modulePath = process.env.PLAYWRIGHT_MODULE_PATH;
try {
return modulePath ? require(modulePath) : require("playwright");
} catch (error) {
throw new Error(
"Playwright is not available. Install the playwright package or set "
+ "PLAYWRIGHT_MODULE_PATH to its module directory. "
+ `Original error: ${error.message}`,
);
}
}
function findBrowserExecutable(chromium) {
const configured = process.env.HASS_PLAYWRIGHT_EXECUTABLE_PATH;
if (configured) {
if (!fs.existsSync(configured)) {
throw new Error(`Configured browser executable does not exist: ${configured}`);
}
return configured;
}
const candidates = [];
try {
candidates.push(chromium.executablePath());
} catch {
// Fall through to installed browser candidates.
}
if (process.platform === "win32") {
candidates.push(
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
);
}
return candidates.find((candidate) => candidate && fs.existsSync(candidate));
}
function safeScreenshotName(route) {
const value = route.replace(/^\/+/, "").replace(/[^A-Za-z0-9_-]+/g, "-");
return value || "root";
}
function expectedView(route) {
const parts = route.split("/").filter(Boolean);
if (parts.length <= 1) {
return { index: 0, path: null };
}
const requested = parts[parts.length - 1];
if (/^\d+$/.test(requested)) {
return { index: Number(requested), path: null };
}
return { index: null, path: requested };
}
function isIgnoredConsoleError(message) {
return [
/ResizeObserver loop/i,
/favicon\.ico/i,
/^Failed to load resource: the server responded with a status of \d+ \([^)]+\)$/,
].some((pattern) => pattern.test(message));
}
function isIgnoredFailedResponse(response) {
if (response.status() !== 404) {
return false;
}
const url = new URL(response.url());
// Bubble Card probes this legacy optional file when Bubble Card Tools is absent.
return url.pathname === "/local/bubble/bubble-modules.yaml";
}
async function validateRoute(page, baseUrl, route, outputDir) {
const consoleErrors = [];
const pageErrors = [];
const failedResponses = [];
const onConsole = (message) => {
if (message.type() === "error" && !isIgnoredConsoleError(message.text())) {
consoleErrors.push(message.text());
}
};
const onPageError = (error) => pageErrors.push(error.message);
const onResponse = (response) => {
if (response.status() >= 400 && !isIgnoredFailedResponse(response)) {
failedResponses.push(`${response.status()} ${response.url()}`);
}
};
page.on("console", onConsole);
page.on("pageerror", onPageError);
page.on("response", onResponse);
const failures = [];
const screenshotPath = path.join(outputDir, `${safeScreenshotName(route)}.png`);
const url = `${baseUrl.replace(/\/$/, "")}${route}`;
try {
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
if (!response || response.status() < 200 || response.status() >= 400) {
failures.push(`HTTP status ${response ? response.status() : "missing"}`);
}
if (/\/(login|auth\/authorize)\b/.test(page.url())) {
failures.push(`redirected to authentication at ${page.url()}`);
}
await page.locator("home-assistant").waitFor({ state: "attached", timeout: 20_000 });
await page.locator("hui-root").waitFor({ state: "attached", timeout: 20_000 });
await page.locator("hui-root").evaluate(async (element) => {
const deadline = Date.now() + 20_000;
while (!element.lovelace?.config?.views) {
if (Date.now() >= deadline) {
throw new Error("Lovelace configuration did not finish loading");
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
await page.waitForTimeout(1_200);
const renderedViews = await page.locator("hui-view").count();
if (renderedViews < 1) {
failures.push("no rendered hui-view found");
}
const errorCards = await page.locator("hui-error-card").count();
if (errorCards > 0) {
failures.push(`${errorCards} hui-error-card element(s) rendered`);
}
const lovelace = await page.locator("hui-root").evaluate((element) => ({
paths: (element.lovelace?.config?.views || []).map((view) => view.path ?? null),
}));
const renderedIndex = await page.locator("hui-view").evaluate((element) => element.index);
const expected = expectedView(route);
if (expected.index !== null && renderedIndex !== expected.index) {
failures.push(
`requested view index ${expected.index}, Lovelace rendered ${renderedIndex}`,
);
}
if (expected.path !== null) {
const expectedIndex = lovelace.paths.indexOf(expected.path);
if (expectedIndex < 0) {
failures.push(`requested view path ${expected.path} is not configured`);
} else if (renderedIndex !== expectedIndex) {
failures.push(
`requested view path ${expected.path} at index ${expectedIndex}, `
+ `Lovelace rendered index ${renderedIndex}`,
);
}
}
if (pageErrors.length > 0) {
failures.push(`page errors: ${pageErrors.join(" | ")}`);
}
if (consoleErrors.length > 0) {
failures.push(`console errors: ${consoleErrors.join(" | ")}`);
}
if (failedResponses.length > 0) {
failures.push(`failed responses: ${failedResponses.join(" | ")}`);
}
} catch (error) {
failures.push(error.message);
} finally {
page.off("console", onConsole);
page.off("pageerror", onPageError);
page.off("response", onResponse);
}
if (failures.length > 0) {
fs.mkdirSync(outputDir, { recursive: true });
try {
await page.screenshot({ path: screenshotPath, fullPage: true });
} catch (error) {
failures.push(`screenshot failed: ${error.message}`);
}
return { route, failures, screenshotPath };
}
if (fs.existsSync(screenshotPath)) {
fs.unlinkSync(screenshotPath);
}
return { route, failures: [], screenshotPath: null };
}
async function main() {
const options = parseArguments(process.argv.slice(2));
let routes;
try {
routes = JSON.parse(options.routesJson);
} catch (error) {
throw new Error(`Invalid routes JSON: ${error.message}`);
}
if (!Array.isArray(routes) || routes.length === 0) {
throw new Error("At least one validated dashboard route is required");
}
if (routes.some((route) => typeof route !== "string" || !route.startsWith("/"))) {
throw new Error("Every dashboard route must be an absolute path string");
}
const { chromium } = loadPlaywright();
const executablePath = findBrowserExecutable(chromium);
const launchOptions = {
headless: !options.headed,
...(executablePath ? { executablePath } : {}),
};
const browser = await chromium.launch(launchOptions);
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const results = [];
try {
for (const route of routes) {
const page = await context.newPage();
try {
const result = await validateRoute(page, options.baseUrl, route, options.outputDir);
results.push(result);
if (result.failures.length === 0) {
console.log(`OK ${route}`);
} else {
console.error(`FAIL ${route}: ${result.failures.join("; ")}`);
console.error(` screenshot: ${result.screenshotPath}`);
}
} finally {
await page.close();
}
}
} finally {
await browser.close();
}
const failed = results.filter((result) => result.failures.length > 0);
if (failed.length > 0) {
console.error(`UI smoke failed: ${failed.length}/${results.length} routes failed`);
process.exitCode = 2;
return;
}
console.log(`UI smoke passed: ${results.length} rendered Lovelace routes validated`);
}
main().catch((error) => {
console.error(`UI smoke setup failed: ${error.message}`);
process.exitCode = 2;
});

@ -1,78 +1,92 @@
param(
[string]$BaseUrl = $env:HASS_PLAYWRIGHT_BASE_URL
[string]$BaseUrl = $env:HASS_PLAYWRIGHT_BASE_URL,
[string]$NodePath = $env:HASS_PLAYWRIGHT_NODE_PATH,
[string]$PlaywrightModulePath = $env:PLAYWRIGHT_MODULE_PATH,
[string]$ExecutablePath = $env:HASS_PLAYWRIGHT_EXECUTABLE_PATH,
[string]$OutputDir,
[string[]]$Routes,
[switch]$Headed
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
# Canonical LAN URL for agent automation (trusted_networks bypass).
$BaseUrl = 'http://192.168.10.10:8123'
}
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = Join-Path $repoRoot 'output/playwright/ha-ui-smoke'
}
$BaseUrl = $BaseUrl.TrimEnd('/')
function Get-EffectiveUrlAndStatus {
param(
[Parameter(Mandatory = $true)]
[string]$Url
)
# Use curl.exe (not Invoke-WebRequest alias) so we can reliably see redirects.
$curl = (Get-Command curl.exe -ErrorAction Stop).Source
# Follow redirects; return final URL + status code.
$out = & $curl -sS -L -o NUL -w "%{url_effective} %{http_code}" $Url 2>$null
if (-not $out) { throw "curl produced no output for $Url" }
$parts = $out -split ' '
if ($parts.Count -lt 2) { throw "Unexpected curl output: $out" }
[pscustomobject]@{
EffectiveUrl = $parts[0]
StatusCode = [int]$parts[1]
if ([string]::IsNullOrWhiteSpace($NodePath)) {
$node = Get-Command node -ErrorAction SilentlyContinue
if ($node) {
$NodePath = $node.Source
}
}
if ([string]::IsNullOrWhiteSpace($NodePath) -or -not (Test-Path -LiteralPath $NodePath)) {
throw 'Node.js not found. Set HASS_PLAYWRIGHT_NODE_PATH or install Node.js.'
}
$targets = @(
'/',
'/lovelace',
'/lovelace/home',
'/lovelace/health',
'/lovelace/cameras',
'/profile',
'/dashboard-infrastructure',
'/dashboard-infrastructure/home',
'/dashboard-infrastructure/home-water',
'/dashboard-infrastructure/vacuum',
'/dashboard-infrastructure/network',
'/dashboard-infrastructure/wan',
'/dashboard-infrastructure/joanna',
'/dashboard-infrastructure/docker',
'/dashboard-kiosk'
)
$failed = $false
foreach ($path in $targets) {
$url = "$BaseUrl$path"
$r = Get-EffectiveUrlAndStatus -Url $url
$isLogin = $r.EffectiveUrl -match '/(login|auth/authorize)\\b'
$statusOk = $r.StatusCode -ge 200 -and $r.StatusCode -lt 400
if ($isLogin -or -not $statusOk) {
$failed = $true
Write-Host ("FAIL {0} -> {1} ({2})" -f $url, $r.EffectiveUrl, $r.StatusCode)
if (-not $Routes -or $Routes.Count -eq 0) {
$python = Get-Command py -ErrorAction SilentlyContinue
$pythonArguments = @()
if ($python) {
$pythonArguments += '-3'
} else {
Write-Host ("OK {0} -> {1} ({2})" -f $url, $r.EffectiveUrl, $r.StatusCode)
$python = Get-Command python -ErrorAction SilentlyContinue
}
if (-not $python) {
throw 'Python 3 not found; it is required to generate validated dashboard routes.'
}
$routesJson = & $python.Source @pythonArguments "$repoRoot/tools/validate_dashboards.py" '--routes-json'
if ($LASTEXITCODE -ne 0) {
throw 'Dashboard validation failed; refusing to start browser smoke tests.'
}
} else {
$routesJson = ConvertTo-Json -Compress -InputObject @($Routes)
}
if ($failed) {
Write-Host ''
Write-Host 'Likely causes:'
Write-Host '- Base URL is external (Cloudflared/Nabu Casa) instead of LAN.'
Write-Host '- Home Assistant does not see the request IP as trusted; check `homeassistant.auth_providers.trusted_networks`.'
exit 1
}
$savedNodePath = $env:NODE_PATH
$savedModulePath = $env:PLAYWRIGHT_MODULE_PATH
$savedExecutablePath = $env:HASS_PLAYWRIGHT_EXECUTABLE_PATH
$savedRoutes = $env:HASS_UI_ROUTES_JSON
try {
if (-not [string]::IsNullOrWhiteSpace($PlaywrightModulePath)) {
if (-not (Test-Path -LiteralPath $PlaywrightModulePath)) {
throw "Playwright module path does not exist: $PlaywrightModulePath"
}
$moduleParent = Split-Path -Parent $PlaywrightModulePath
$pnpmModules = Join-Path $moduleParent '.pnpm/node_modules'
$nodePaths = @($moduleParent)
if (Test-Path -LiteralPath $pnpmModules) {
$nodePaths += $pnpmModules
}
$env:NODE_PATH = $nodePaths -join [IO.Path]::PathSeparator
$env:PLAYWRIGHT_MODULE_PATH = $PlaywrightModulePath
}
if (-not [string]::IsNullOrWhiteSpace($ExecutablePath)) {
$env:HASS_PLAYWRIGHT_EXECUTABLE_PATH = $ExecutablePath
}
$env:HASS_UI_ROUTES_JSON = [string]$routesJson
exit 0
$arguments = @(
"$repoRoot/tools/ha_ui_smoke.mjs"
'--base-url'
$BaseUrl.TrimEnd('/')
'--output-dir'
$OutputDir
)
if ($Headed) {
$arguments += '--headed'
}
& $NodePath @arguments
exit $LASTEXITCODE
} finally {
$env:NODE_PATH = $savedNodePath
$env:PLAYWRIGHT_MODULE_PATH = $savedModulePath
$env:HASS_PLAYWRIGHT_EXECUTABLE_PATH = $savedExecutablePath
$env:HASS_UI_ROUTES_JSON = $savedRoutes
}

@ -4,10 +4,18 @@ 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."
$python = Get-Command py -ErrorAction SilentlyContinue
$arguments = @()
if ($python) {
$arguments += '-3'
} else {
$python = Get-Command python -ErrorAction SilentlyContinue
}
py -3 "$repoRoot\tools\validate_dashboards.py"
if (-not $python) {
throw "Python 3 not found. Install Python 3 and the pinned requirements in requirements-dev.txt."
}
& $python.Source @arguments "$repoRoot/tools/validate_dashboards.py"
exit $LASTEXITCODE

@ -1,198 +1,499 @@
#!/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.
"""
"""Strict validation for YAML-managed Home Assistant Lovelace dashboards."""
from __future__ import annotations
import os
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
import yaml
from yaml.constructor import ConstructorError
HEADER_MARKER = "######################################################################"
HEADER_REQUIRED = (
"# @CCOSTAN - Follow Me on X",
"# For more info visit https://www.vcloudinfo.com/click-here",
"# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig",
)
INCLUDE_TAGS = {
"!include",
"!include_dir_list",
"!include_dir_merge_list",
"!include_dir_named",
"!include_dir_merge_named",
}
ENTITY_ID_RE = re.compile(r"^[a-z][a-z0-9_]*\.[a-z0-9_]+$")
ENTITY_ID_SEARCH_RE = re.compile(
r"(?<![A-Za-z0-9_])([a-z][a-z0-9_]*\.[a-z0-9_]+)(?![A-Za-z0-9_])"
)
VIEW_PATH_RE = re.compile(r"^[A-Za-z0-9_-]+$")
ENTITY_KEYS = {"entity", "entity_id", "camera_image"}
class ValidationError(RuntimeError):
"""A repository validation failure with a user-actionable message."""
@dataclass(frozen=True)
class TaggedValue:
tag: str
value: Any
@dataclass(frozen=True)
class ValidationResult:
dashboard_count: int
parsed_files: tuple[Path, ...]
routes: tuple[str, ...]
entity_references: tuple[str, ...]
class HomeAssistantLoader(yaml.SafeLoader):
"""Safe YAML loader that preserves Home Assistant custom tags."""
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
if not isinstance(node, yaml.MappingNode):
raise ConstructorError(None, None, "expected a mapping node", node.start_mark)
mapping: dict[Any, Any] = {}
for key_node, value_node in node.value:
key = self.construct_object(key_node, deep=deep)
try:
duplicate = key in mapping
except TypeError as exc:
raise ConstructorError(
"while constructing a mapping",
node.start_mark,
"found an unhashable key",
key_node.start_mark,
) from exc
if duplicate:
raise ConstructorError(
"while constructing a mapping",
node.start_mark,
f"found duplicate key {key!r}",
key_node.start_mark,
)
mapping[key] = self.construct_object(value_node, deep=deep)
return mapping
# Home Assistant treats on/off/yes/no as strings rather than YAML 1.1 booleans.
HomeAssistantLoader.yaml_implicit_resolvers = {
key: list(value) for key, value in yaml.SafeLoader.yaml_implicit_resolvers.items()
}
for resolver_key, resolvers in list(HomeAssistantLoader.yaml_implicit_resolvers.items()):
HomeAssistantLoader.yaml_implicit_resolvers[resolver_key] = [
resolver for resolver in resolvers if resolver[0] != "tag:yaml.org,2002:bool"
]
def _construct_tagged(
loader: HomeAssistantLoader, tag_suffix: str, node: yaml.Node
) -> TaggedValue:
if isinstance(node, yaml.ScalarNode):
value = loader.construct_scalar(node)
elif isinstance(node, yaml.SequenceNode):
value = loader.construct_sequence(node, deep=True)
elif isinstance(node, yaml.MappingNode):
value = loader.construct_mapping(node, deep=True)
else: # pragma: no cover - PyYAML currently exposes only these node types.
raise ConstructorError(None, None, "unsupported tagged YAML node", node.start_mark)
return TaggedValue(f"!{tag_suffix}", value)
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*$")
HomeAssistantLoader.add_multi_constructor("!", _construct_tagged)
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
def parse_yaml(path: Path) -> Any:
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ValidationError(f"cannot read YAML file {path}: {exc}") from exc
# Return block content excluding the "<key>:" line itself.
return "\n".join(lines[start + 1 : end]) + "\n"
try:
return yaml.load(text, Loader=HomeAssistantLoader)
except yaml.YAMLError as exc:
raise ValidationError(f"invalid YAML in {path}: {exc}") from exc
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 _yaml_files(path: Path) -> list[Path]:
return sorted(
child.resolve()
for child in path.iterdir()
if child.is_file() and child.suffix.lower() in {".yaml", ".yml"}
)
class DashboardRepositoryValidator:
def __init__(self, root: Path) -> None:
self.root = root.resolve()
self.config_dir = (self.root / "config").resolve()
self.dashboard_dir = (self.config_dir / "dashboards").resolve()
self._cache: dict[Path, Any] = {}
self._visiting: set[Path] = set()
self.parsed_files: set[Path] = set()
self.dashboard_documents: list[Any] = []
def validate_includes(root: Path, yaml_files: list[Path]) -> list[str]:
errs: list[str] = []
for p in yaml_files:
def _ensure_config_path(self, path: Path) -> Path:
resolved = path.resolve()
try:
resolved.relative_to(self.config_dir)
except ValueError as exc:
raise ValidationError(f"include escapes config directory: {resolved}") from exc
return resolved
def resolve_target(self, source: Path, target: str) -> Path:
if not isinstance(target, str) or not target.strip():
raise ValidationError(f"empty include target in {source}")
target = target.strip()
if target.startswith("/config/"):
candidate = self.config_dir / target[len("/config/") :]
elif target == "/config":
candidate = self.config_dir
else:
candidate = source.parent / target
return self._ensure_config_path(candidate)
def load_file(self, path: Path) -> Any:
path = self._ensure_config_path(path)
if not path.exists():
raise ValidationError(f"missing include file: {path}")
if not path.is_file():
raise ValidationError(f"include target is not a file: {path}")
if path in self._cache:
return self._cache[path]
if path in self._visiting:
raise ValidationError(f"cyclic YAML include detected at {path}")
self._visiting.add(path)
try:
text = p.read_text(encoding="utf-8", errors="replace")
except OSError as e:
errs.append(f"ERROR: cannot read {p}: {e}")
document = parse_yaml(path)
self.parsed_files.add(path)
resolved = self.resolve_value(document, path)
self._cache[path] = resolved
return resolved
finally:
self._visiting.remove(path)
def _load_directory(self, source: Path, tagged: TaggedValue) -> Any:
directory = self.resolve_target(source, str(tagged.value))
if not directory.exists():
raise ValidationError(f"missing include directory: {directory}")
if not directory.is_dir():
raise ValidationError(f"include target is not a directory: {directory}")
files = _yaml_files(directory)
if not files:
raise ValidationError(f"include directory contains no YAML files: {directory}")
documents = [self.load_file(path) for path in files]
if tagged.tag == "!include_dir_list":
return documents
if tagged.tag == "!include_dir_merge_list":
merged: list[Any] = []
for path, document in zip(files, documents):
if not isinstance(document, list):
raise ValidationError(
f"{tagged.tag} requires every file to contain a list: {path}"
)
merged.extend(document)
return merged
if tagged.tag == "!include_dir_named":
return {path.stem: document for path, document in zip(files, documents)}
if tagged.tag == "!include_dir_merge_named":
merged_mapping: dict[Any, Any] = {}
for path, document in zip(files, documents):
if not isinstance(document, dict):
raise ValidationError(
f"{tagged.tag} requires every file to contain a mapping: {path}"
)
duplicates = set(merged_mapping).intersection(document)
if duplicates:
duplicate_text = ", ".join(sorted(map(str, duplicates)))
raise ValidationError(
f"duplicate merged keys in {path}: {duplicate_text}"
)
merged_mapping.update(document)
return merged_mapping
raise ValidationError(f"unsupported include directory tag {tagged.tag} in {source}")
def resolve_value(self, value: Any, source: Path) -> Any:
if isinstance(value, TaggedValue):
if value.tag == "!include":
return self.load_file(self.resolve_target(source, str(value.value)))
if value.tag in INCLUDE_TAGS:
return self._load_directory(source, value)
return TaggedValue(value.tag, self.resolve_value(value.value, source))
if isinstance(value, list):
return [self.resolve_value(item, source) for item in value]
if isinstance(value, dict):
return {
key: self.resolve_value(item, source) for key, item in value.items()
}
return value
def validate_headers(self) -> None:
if not self.dashboard_dir.exists():
raise ValidationError(f"missing dashboards directory: {self.dashboard_dir}")
for path in sorted(self.dashboard_dir.rglob("*.yaml")):
try:
head = path.read_text(encoding="utf-8").splitlines()[:8]
except (OSError, UnicodeError) as exc:
raise ValidationError(f"cannot read dashboard header {path}: {exc}") from exc
if not head or head[0] != HEADER_MARKER:
raise ValidationError(f"missing standard header delimiter: {path}")
missing = [line for line in HEADER_REQUIRED if line not in head]
if missing:
raise ValidationError(
f"incomplete standard header in {path}; missing {missing[0]!r}"
)
def validate_dashboard(self, slug: str, document: Any) -> list[str]:
if not isinstance(document, dict):
raise ValidationError(f"dashboard {slug!r} must resolve to a mapping")
views = document.get("views")
if not isinstance(views, list) or not views:
raise ValidationError(f"dashboard {slug!r} must contain a non-empty views list")
paths: list[str] = []
for index, view in enumerate(views):
if not isinstance(view, dict):
raise ValidationError(
f"dashboard {slug!r} view {index} must resolve to a mapping"
)
path = view.get("path")
if path is None:
# Home Assistant uses the zero-based view index when path is omitted.
path = str(index)
if not isinstance(path, str) or not VIEW_PATH_RE.fullmatch(path):
raise ValidationError(
f"dashboard {slug!r} view {index} has invalid path {path!r}"
)
if path in paths:
raise ValidationError(
f"dashboard {slug!r} contains duplicate view path {path!r}"
)
paths.append(path)
self.dashboard_documents.append(document)
return paths
def validate_configuration(self) -> tuple[int, list[str]]:
configuration_path = self.config_dir / "configuration.yaml"
if not configuration_path.exists():
raise ValidationError(f"missing configuration file: {configuration_path}")
configuration = parse_yaml(configuration_path)
if not isinstance(configuration, dict):
raise ValidationError("config/configuration.yaml must contain a mapping")
lovelace = configuration.get("lovelace")
if not isinstance(lovelace, dict):
raise ValidationError("config/configuration.yaml is missing lovelace mapping")
if "mode" in lovelace:
raise ValidationError(
"legacy lovelace.mode is not allowed; declare YAML dashboards instead"
)
if lovelace.get("resource_mode") != "yaml":
raise ValidationError("lovelace.resource_mode must be yaml")
resources = lovelace.get("resources")
if not isinstance(resources, TaggedValue) or resources.tag != "!include":
raise ValidationError("lovelace.resources must use !include")
resolved_resources = self.resolve_value(resources, configuration_path)
if not isinstance(resolved_resources, list):
raise ValidationError("lovelace resources include must resolve to a list")
dashboards = lovelace.get("dashboards")
if not isinstance(dashboards, dict) or not dashboards:
raise ValidationError("lovelace.dashboards must be a non-empty mapping")
routes: list[str] = []
for slug, entry in dashboards.items():
if not isinstance(slug, str) or (
slug != "lovelace" and not slug.startswith("dashboard-")
):
raise ValidationError(f"invalid dashboard slug {slug!r}")
if not isinstance(entry, dict):
raise ValidationError(f"dashboard declaration {slug!r} must be a mapping")
if entry.get("mode") != "yaml":
raise ValidationError(f"dashboard {slug!r} must use mode: yaml")
filename = entry.get("filename")
if not isinstance(filename, str) or not filename.strip():
raise ValidationError(f"dashboard {slug!r} is missing filename")
document = self.load_file(self.resolve_target(configuration_path, filename))
view_paths = self.validate_dashboard(slug, document)
base_route = "/lovelace" if slug == "lovelace" else f"/{slug}"
routes.append(base_route)
routes.extend(f"{base_route}/{path}" for path in view_paths)
return len(dashboards), routes
def validate_no_orphans(self) -> None:
all_dashboard_files = {path.resolve() for path in self.dashboard_dir.rglob("*.yaml")}
parsed_dashboard_files = {
path for path in self.parsed_files if path.is_relative_to(self.dashboard_dir)
}
orphaned = sorted(all_dashboard_files - parsed_dashboard_files)
if orphaned:
display = ", ".join(str(path.relative_to(self.root)) for path in orphaned[:10])
suffix = " ..." if len(orphaned) > 10 else ""
raise ValidationError(f"orphaned dashboard YAML files: {display}{suffix}")
def collect_entity_references(self) -> set[str]:
found: set[str] = set()
def add_candidate(value: Any) -> None:
if isinstance(value, str) and ENTITY_ID_RE.fullmatch(value.strip()):
found.add(value.strip())
elif isinstance(value, list):
for item in value:
add_candidate(item)
def walk(value: Any) -> None:
if isinstance(value, dict):
for key, item in value.items():
if key in ENTITY_KEYS:
add_candidate(item)
if key == "entities" and isinstance(item, list):
for entity_item in item:
if isinstance(entity_item, str):
add_candidate(entity_item)
elif isinstance(entity_item, dict):
add_candidate(entity_item.get("entity"))
walk(item)
elif isinstance(value, list):
for item in value:
walk(item)
elif isinstance(value, TaggedValue):
walk(value.value)
for document in self.dashboard_documents:
walk(document)
return found
def _entity_ids_in_text(path: Path) -> set[str]:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return set()
return {match.group(1) for match in ENTITY_ID_SEARCH_RE.finditer(text)}
def repository_entity_catalog(root: Path) -> set[str]:
config_dir = root / "config"
catalog: set[str] = set()
for path in config_dir.rglob("*.yaml"):
if "dashboards" not in path.parts and ".storage" not in path.parts:
catalog.update(_entity_ids_in_text(path))
return catalog
def live_entity_catalog(root: Path) -> set[str]:
storage = root / "config" / ".storage"
catalog: set[str] = set()
for name in ("core.entity_registry", "core.restore_state"):
path = storage / name
if path.exists():
catalog.update(_entity_ids_in_text(path))
return catalog
def read_entity_allowlist(root: Path) -> set[str]:
path = root / "tools" / "dashboard_entity_allowlist.txt"
if not path.exists():
return set()
values: set[str] = set()
for number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw_line.strip()
if not line or line.startswith("#"):
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."
if not ENTITY_ID_RE.fullmatch(line):
raise ValidationError(f"invalid entity ID in {path}:{number}: {line!r}")
values.add(line)
return values
def validate_entity_references(root: Path, references: set[str]) -> None:
repository_catalog = repository_entity_catalog(root)
live_catalog = live_entity_catalog(root)
allowlist = read_entity_allowlist(root)
known = repository_catalog | live_catalog | allowlist
unknown = sorted(references - known)
if unknown:
raise ValidationError(
"unknown static dashboard entity references: " + ", ".join(unknown[:20])
)
# 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))
stale_allowlist = sorted(allowlist - references)
if stale_allowlist:
raise ValidationError(
"stale dashboard entity allowlist entries: " + ", ".join(stale_allowlist[:20])
)
if errs:
for e in errs:
print(e)
print(f"FAIL ({len(errs)} issues)")
if live_catalog:
unverifiable = sorted(allowlist - repository_catalog - live_catalog)
if unverifiable:
raise ValidationError(
"allowlisted dashboard entities are absent from live storage: "
+ ", ".join(unverifiable[:20])
)
def validate_repository(root: Path | None = None) -> ValidationResult:
root = (root or repo_root()).resolve()
validator = DashboardRepositoryValidator(root)
validator.validate_headers()
dashboard_count, routes = validator.validate_configuration()
validator.validate_no_orphans()
references = validator.collect_entity_references()
validate_entity_references(root, references)
return ValidationResult(
dashboard_count=dashboard_count,
parsed_files=tuple(sorted(validator.parsed_files)),
routes=tuple(routes),
entity_references=tuple(sorted(references)),
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=repo_root())
parser.add_argument(
"--routes-json",
action="store_true",
help="print the validated dashboard route list as JSON",
)
return parser
def main(argv: Iterable[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = validate_repository(args.root)
except ValidationError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
print(f"OK: validated {len(yaml_files)} dashboard YAML files")
if args.routes_json:
print(json.dumps(result.routes))
else:
print(
"OK: validated "
f"{result.dashboard_count} dashboards, "
f"{len(result.routes)} routes, "
f"{len(result.parsed_files)} YAML files, and "
f"{len(result.entity_references)} static entity references"
)
return 0

Loading…
Cancel
Save

Powered by TurnKey Linux.