From dc8a4897c60d57035f78b02fef810c3ca3c7e170 Mon Sep 17 00:00:00 2001 From: Carlo Costanzo Date: Tue, 28 Jul 2026 17:13:26 -0400 Subject: [PATCH] Refine dashboard validation and UI smoke checks --- .../partials/activity_sections.yaml | 22 - .../partials/home_sections.yaml | 114 ++-- .../infra_top_chips_home_section.yaml | 35 - .../partials/infra_top_chips_section.yaml | 41 -- .../partials/network_sections.yaml | 28 +- .../partials/proxmox_badges.yaml | 37 - .../partials/proxmox_sections.yaml | 8 + .../infrastructure/partials/view_cards.yaml | 15 - .../templates/button_card_templates.yaml | 14 + .../infrastructure/views/01_home.yaml | 2 +- .../infrastructure/views/10_sprinklers.yaml | 34 - tests/test_validate_dashboards.py | 159 +++++ tools/dashboard_entity_allowlist.txt | 138 ++++ tools/ha_ui_smoke.mjs | 294 ++++++++ tools/ha_ui_smoke.ps1 | 134 ++-- tools/validate_dashboards.ps1 | 16 +- tools/validate_dashboards.py | 637 +++++++++++++----- 17 files changed, 1252 insertions(+), 476 deletions(-) delete mode 100644 config/dashboards/infrastructure/partials/activity_sections.yaml delete mode 100644 config/dashboards/infrastructure/partials/infra_top_chips_home_section.yaml delete mode 100644 config/dashboards/infrastructure/partials/infra_top_chips_section.yaml delete mode 100644 config/dashboards/infrastructure/partials/proxmox_badges.yaml delete mode 100644 config/dashboards/infrastructure/partials/view_cards.yaml create mode 100644 tests/test_validate_dashboards.py create mode 100644 tools/dashboard_entity_allowlist.txt create mode 100644 tools/ha_ui_smoke.mjs diff --git a/config/dashboards/infrastructure/partials/activity_sections.yaml b/config/dashboards/infrastructure/partials/activity_sections.yaml deleted file mode 100644 index 01635630..00000000 --- a/config/dashboards/infrastructure/partials/activity_sections.yaml +++ /dev/null @@ -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 diff --git a/config/dashboards/infrastructure/partials/home_sections.yaml b/config/dashboards/infrastructure/partials/home_sections.yaml index 0cd1e4c4..570a5301 100644 --- a/config/dashboards/infrastructure/partials/home_sections.yaml +++ b/config/dashboards/infrastructure/partials/home_sections.yaml @@ -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 diff --git a/config/dashboards/infrastructure/partials/infra_top_chips_home_section.yaml b/config/dashboards/infrastructure/partials/infra_top_chips_home_section.yaml deleted file mode 100644 index 8631be6c..00000000 --- a/config/dashboards/infrastructure/partials/infra_top_chips_home_section.yaml +++ /dev/null @@ -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 diff --git a/config/dashboards/infrastructure/partials/infra_top_chips_section.yaml b/config/dashboards/infrastructure/partials/infra_top_chips_section.yaml deleted file mode 100644 index a3175442..00000000 --- a/config/dashboards/infrastructure/partials/infra_top_chips_section.yaml +++ /dev/null @@ -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 - diff --git a/config/dashboards/infrastructure/partials/network_sections.yaml b/config/dashboards/infrastructure/partials/network_sections.yaml index 5264e37c..7dc7beb7 100644 --- a/config/dashboards/infrastructure/partials/network_sections.yaml +++ b/config/dashboards/infrastructure/partials/network_sections.yaml @@ -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 diff --git a/config/dashboards/infrastructure/partials/proxmox_badges.yaml b/config/dashboards/infrastructure/partials/proxmox_badges.yaml deleted file mode 100644 index d34fdbec..00000000 --- a/config/dashboards/infrastructure/partials/proxmox_badges.yaml +++ /dev/null @@ -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 diff --git a/config/dashboards/infrastructure/partials/proxmox_sections.yaml b/config/dashboards/infrastructure/partials/proxmox_sections.yaml index d4653b78..e5d0e079 100644 --- a/config/dashboards/infrastructure/partials/proxmox_sections.yaml +++ b/config/dashboards/infrastructure/partials/proxmox_sections.yaml @@ -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 diff --git a/config/dashboards/infrastructure/partials/view_cards.yaml b/config/dashboards/infrastructure/partials/view_cards.yaml deleted file mode 100644 index 8c3a4abc..00000000 --- a/config/dashboards/infrastructure/partials/view_cards.yaml +++ /dev/null @@ -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 diff --git a/config/dashboards/infrastructure/templates/button_card_templates.yaml b/config/dashboards/infrastructure/templates/button_card_templates.yaml index 0f78805d..1f89fd3c 100644 --- a/config/dashboards/infrastructure/templates/button_card_templates.yaml +++ b/config/dashboards/infrastructure/templates/button_card_templates.yaml @@ -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}`; diff --git a/config/dashboards/infrastructure/views/01_home.yaml b/config/dashboards/infrastructure/views/01_home.yaml index dc616435..e54e7a47 100644 --- a/config/dashboards/infrastructure/views/01_home.yaml +++ b/config/dashboards/infrastructure/views/01_home.yaml @@ -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`. ###################################################################### diff --git a/config/dashboards/infrastructure/views/10_sprinklers.yaml b/config/dashboards/infrastructure/views/10_sprinklers.yaml index c6a59745..b651fdc1 100644 --- a/config/dashboards/infrastructure/views/10_sprinklers.yaml +++ b/config/dashboards/infrastructure/views/10_sprinklers.yaml @@ -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 diff --git a/tests/test_validate_dashboards.py b/tests/test_validate_dashboards.py new file mode 100644 index 00000000..c098f975 --- /dev/null +++ b/tests/test_validate_dashboards.py @@ -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() diff --git a/tools/dashboard_entity_allowlist.txt b/tools/dashboard_entity_allowlist.txt new file mode 100644 index 00000000..a2124b45 --- /dev/null +++ b/tools/dashboard_entity_allowlist.txt @@ -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 diff --git a/tools/ha_ui_smoke.mjs b/tools/ha_ui_smoke.mjs new file mode 100644 index 00000000..abf6cc6b --- /dev/null +++ b/tools/ha_ui_smoke.mjs @@ -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; +}); diff --git a/tools/ha_ui_smoke.ps1 b/tools/ha_ui_smoke.ps1 index 0f664299..57879470 100644 --- a/tools/ha_ui_smoke.ps1 +++ b/tools/ha_ui_smoke.ps1 @@ -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 +} diff --git a/tools/validate_dashboards.ps1 b/tools/validate_dashboards.ps1 index d39f0b7b..74125f92 100644 --- a/tools/validate_dashboards.ps1 +++ b/tools/validate_dashboards.ps1 @@ -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 diff --git a/tools/validate_dashboards.py b/tools/validate_dashboards.py index 9b5853b1..c5412bff 100644 --- a/tools/validate_dashboards.py +++ b/tools/validate_dashboards.py @@ -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"(? 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 ":" 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