Enhance Home Assistant dashboard designer documentation and validation scripts #1566
- Added workflow notes for direct updates and post-edit validation steps in README.md. - Updated SKILL.md to clarify the workflow for editing dashboards and added validation order. - Revised dashboard_rules.md to emphasize direct-update workflow and validation requirements. - Enhanced validate_lovelace_view.py to enforce new validation rules for card styles and section constraints. - Improved error handling and validation logic for dashboard sections in validate_lovelace_view.py.feature/powerwall-live-activity-1598
parent
b3e039e509
commit
1d8aed02df
@ -0,0 +1,127 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "scripts" / "validate_lovelace_view.py"
|
||||
)
|
||||
MODULE_NAME = "validate_lovelace_view"
|
||||
SPEC = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load module spec from {MODULE_PATH}")
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[MODULE_NAME] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class ValidateLovelaceViewTests(unittest.TestCase):
|
||||
def _run_validator(self, yaml_text: str, *extra_args: str) -> tuple[int, str, str]:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
view_path = Path(tmpdir) / "view.yaml"
|
||||
view_path.write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
rc = MODULE.main([str(view_path), *extra_args])
|
||||
return rc, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def test_valid_classic_view_passes(self) -> None:
|
||||
yaml_text = """
|
||||
title: Main
|
||||
path: main
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
template:
|
||||
- status_chip
|
||||
"""
|
||||
rc, out, err = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertEqual(out.strip(), "")
|
||||
self.assertEqual(err.strip(), "")
|
||||
|
||||
def test_horizontal_stack_fails(self) -> None:
|
||||
yaml_text = """
|
||||
title: Main
|
||||
path: main
|
||||
cards:
|
||||
- type: horizontal-stack
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
template: status_chip
|
||||
"""
|
||||
rc, out, _ = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("Disallowed card type: horizontal-stack", out)
|
||||
|
||||
def test_button_card_missing_template_fails(self) -> None:
|
||||
yaml_text = """
|
||||
title: Main
|
||||
path: main
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
entity: light.kitchen
|
||||
"""
|
||||
rc, out, _ = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("custom:button-card must set template", out)
|
||||
|
||||
def test_sections_wrapper_rule_fails_when_full_width_missing(self) -> None:
|
||||
yaml_text = """
|
||||
title: Sections
|
||||
path: sections
|
||||
type: sections
|
||||
sections:
|
||||
- type: grid
|
||||
column_span: 4
|
||||
cards:
|
||||
- type: custom:vertical-stack-in-card
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
template: status_chip
|
||||
"""
|
||||
rc, out, _ = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn('Wrapper card must set grid_options.columns: "full".', out)
|
||||
|
||||
def test_sections_wrapper_rule_passes_when_full_width_wrapper_present(self) -> None:
|
||||
yaml_text = """
|
||||
title: Sections
|
||||
path: sections
|
||||
type: sections
|
||||
sections:
|
||||
- type: grid
|
||||
column_span: 4
|
||||
cards:
|
||||
- type: custom:vertical-stack-in-card
|
||||
grid_options:
|
||||
columns: "full"
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
template: status_chip
|
||||
"""
|
||||
rc, out, err = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertEqual(out.strip(), "")
|
||||
self.assertEqual(err.strip(), "")
|
||||
|
||||
def test_unknown_include_tag_is_parse_safe(self) -> None:
|
||||
yaml_text = """
|
||||
title: Included Sections
|
||||
path: included_sections
|
||||
type: sections
|
||||
sections: !include ../sections/core.yaml
|
||||
"""
|
||||
rc, out, err = self._run_validator(yaml_text)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("validator cannot inspect included sections content", out)
|
||||
self.assertEqual(err.strip(), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,88 @@
|
||||
<h1 align="center">
|
||||
<a name="logo" href="https://www.vCloudInfo.com/tag/iot"><img src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/x_profile.png" alt="Bear Stone Smart Home" width="200"></a>
|
||||
<br>
|
||||
Bear Stone Smart Home Documentation
|
||||
</h1>
|
||||
<h4 align="center">Be sure to :star: my configuration repo so you can keep up to date on any daily progress!</h4>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://x.com/ccostan)
|
||||
[](https://www.youtube.com/vCloudInfo?sub_confirmation=1)
|
||||
[](https://github.com/CCOSTAN) <br>
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/blob/master/config/.HA_VERSION)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
|
||||
[](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master)
|
||||
|
||||
</div>
|
||||
|
||||
# HA YAML DRY Verifier (Codex Skill)
|
||||
|
||||
This directory contains the `homeassistant-yaml-dry-verifier` skill and the CLI used to detect repeated YAML structures in Home Assistant automations/scripts/packages.
|
||||
|
||||
### Quick navigation
|
||||
- You are here: `codex_skills/homeassistant-yaml-dry-verifier/`
|
||||
- [Repo overview](../../README.md) | [Codex skills](../README.md) | [Packages](../../config/packages/README.md) | [Scripts](../../config/script/README.md)
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
- Detects repeated `trigger`, `condition`, `action`, and `sequence` blocks.
|
||||
- Detects repeated entries inside those blocks.
|
||||
- Detects duplicate entries within a single block (`INTRA`).
|
||||
- Detects package-defined scripts called from multiple files (`CENTRAL_SCRIPT`).
|
||||
- Collapses noisy ENTRY reports when they are already fully explained by an identical `FULL_BLOCK` finding.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Run on one file:
|
||||
|
||||
```bash
|
||||
python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages/bearclaw.yaml
|
||||
```
|
||||
|
||||
Run on broader scope:
|
||||
|
||||
```bash
|
||||
python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages config/script
|
||||
```
|
||||
|
||||
Strict mode (non-zero exit if findings exist):
|
||||
|
||||
```bash
|
||||
python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages config/script --strict
|
||||
```
|
||||
|
||||
## Output Model
|
||||
|
||||
The CLI prints:
|
||||
- Scan summary counts
|
||||
- `FULL_BLOCK` findings
|
||||
- `ENTRY` findings
|
||||
- `INTRA` findings
|
||||
- `CENTRAL_SCRIPT` findings
|
||||
|
||||
Exit codes:
|
||||
- `0`: success (or findings in non-strict mode)
|
||||
- `1`: findings present in strict mode
|
||||
- `2`: parse/path errors
|
||||
|
||||
## Notes
|
||||
|
||||
- This verifier intentionally keeps text output and a small CLI surface.
|
||||
- It does not implement suppression files, severity scoring, JSON output, or diff-only mode.
|
||||
- Use it as a fast pre-refactor signal and pair with Home Assistant config validation before restart/reload.
|
||||
|
||||
**All of my configuration files are tested against the most stable version of home-assistant.**
|
||||
|
||||
<a name="bottom" href="https://github.com/CCOSTAN/Home-AssistantConfig#logo"><img align="right" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/up_arrow.png" width="25" ></a>
|
||||
|
||||
**Still have questions on my Config?** <br>
|
||||
**Message me on X :** [](https://www.x.com/ccostan)
|
||||
|
||||
<p align="center">
|
||||
<a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"><span style="margin-left:5px">You can buy me a coffee</span></a><a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"></a>
|
||||
<br>
|
||||
<a href="https://eepurl.com/dmXFYz"><img align="center" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/email_link.png" height="50" ></a><br>
|
||||
<a href="https://www.vcloudinfo.com/p/affiliate-disclosure.html">
|
||||
Affiliate Disclosure
|
||||
</a></p>
|
||||
@ -0,0 +1,302 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "scripts" / "verify_ha_yaml_dry.py"
|
||||
)
|
||||
MODULE_NAME = "verify_ha_yaml_dry"
|
||||
SPEC = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load module spec from {MODULE_PATH}")
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[MODULE_NAME] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class VerifyHaYamlDryTests(unittest.TestCase):
|
||||
def _run_verifier(
|
||||
self,
|
||||
files: dict[str, str],
|
||||
*extra_args: str,
|
||||
scan_subpath: str = ".",
|
||||
) -> tuple[int, str, str]:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
for rel_path, content in files.items():
|
||||
file_path = root / rel_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
scan_path = root / scan_subpath
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
rc = MODULE.main([str(scan_path), *extra_args])
|
||||
return rc, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _full_block_group_order(output: str) -> list[str]:
|
||||
marker = "\nFULL_BLOCK findings:\n"
|
||||
if marker not in output:
|
||||
return []
|
||||
section = output.split(marker, 1)[1]
|
||||
for stop in ("\nENTRY findings:\n", "\nINTRA findings:\n", "\nCENTRAL_SCRIPT findings:\n"):
|
||||
if stop in section:
|
||||
section = section.split(stop, 1)[0]
|
||||
break
|
||||
|
||||
groups: list[str] = []
|
||||
for line in section.splitlines():
|
||||
text = line.strip()
|
||||
match = re.match(r"^\d+\.\s+([a-z]+\.[a-z_]+)\s+repeated\s+\d+\s+times$", text)
|
||||
if match:
|
||||
groups.append(match.group(1))
|
||||
return groups
|
||||
|
||||
def test_full_block_detection_with_fixture(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
"""
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("Duplicate full-block groups: 1", out)
|
||||
self.assertIn("FULL_BLOCK findings:", out)
|
||||
self.assertIn("automation.action repeated 2 times", out)
|
||||
|
||||
def test_entry_detection_with_fixture(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: script.shared_handler
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: script.shared_handler
|
||||
- delay: "00:00:01"
|
||||
"""
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("Duplicate entry groups: 1", out)
|
||||
self.assertIn("ENTRY findings:", out)
|
||||
self.assertIn("automation.action entry repeated 2 times", out)
|
||||
|
||||
def test_intra_detection_with_fixture(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: Repeat Inside Block
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.den
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.den
|
||||
"""
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("INTRA findings:", out)
|
||||
self.assertIn("INTRA automation.action: Repeat Inside Block has 2 duplicated entries", out)
|
||||
|
||||
def test_central_script_detection_with_package_definition_and_multi_caller(self) -> None:
|
||||
files = {
|
||||
"config/packages/shared_scripts.yaml": """
|
||||
script:
|
||||
my_shared_script:
|
||||
alias: Shared Script
|
||||
sequence:
|
||||
- service: logbook.log
|
||||
data:
|
||||
name: Shared
|
||||
message: Hello
|
||||
""",
|
||||
"config/automations/caller_one.yaml": """
|
||||
automation:
|
||||
- alias: Caller One
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: script.my_shared_script
|
||||
""",
|
||||
"config/automations/caller_two.yaml": """
|
||||
automation:
|
||||
- alias: Caller Two
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: script.turn_on
|
||||
target:
|
||||
entity_id: script.my_shared_script
|
||||
""",
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("Central-script findings: 1", out)
|
||||
self.assertIn("script.my_shared_script is package-defined and called from 2 files", out)
|
||||
self.assertIn("suggestion: Move definition to config/script/my_shared_script.yaml", out)
|
||||
|
||||
def test_subsumed_entry_groups_are_collapsed(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
"""
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
self.assertIn("Duplicate full-block groups: 1", out)
|
||||
self.assertIn("Duplicate entry groups: 0", out)
|
||||
|
||||
def test_full_block_findings_order_is_deterministic(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.same
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.same
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
""",
|
||||
"scripts.yaml": """
|
||||
script:
|
||||
script_one:
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
script_two:
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
""",
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 0, out + err)
|
||||
order = self._full_block_group_order(out)
|
||||
self.assertGreaterEqual(len(order), 3, out)
|
||||
self.assertEqual(order[:3], ["automation.action", "automation.trigger", "script.sequence"])
|
||||
|
||||
def test_exit_codes_for_strict_and_non_strict_modes(self) -> None:
|
||||
files = {
|
||||
"automations.yaml": """
|
||||
- alias: A1
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
- alias: A2
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.two
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
"""
|
||||
}
|
||||
rc_non_strict, _, _ = self._run_verifier(files)
|
||||
rc_strict, _, _ = self._run_verifier(files, "--strict")
|
||||
self.assertEqual(rc_non_strict, 0)
|
||||
self.assertEqual(rc_strict, 1)
|
||||
|
||||
def test_parse_error_path_returns_exit_code_two(self) -> None:
|
||||
files = {
|
||||
"good.yaml": """
|
||||
automation:
|
||||
- alias: Good
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.one
|
||||
to: "on"
|
||||
action:
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
""",
|
||||
"bad.yaml": "automation: [\n",
|
||||
}
|
||||
rc, out, err = self._run_verifier(files)
|
||||
self.assertEqual(rc, 2, out + err)
|
||||
self.assertIn("Parse errors:", out)
|
||||
self.assertIn("bad.yaml", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in new issue