Add deterministic Home Assistant validation gate

pull/1898/head
Carlo Costanzo 2 months ago
parent dc8a4897c6
commit f669b79f4d

@ -0,0 +1,59 @@
######################################################################
# @CCOSTAN - Follow Me on X
# For more info visit https://www.vcloudinfo.com/click-here
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
# -------------------------------------------------------------------
# Config Validation - Deterministic repository validation gate.
# Runs mutation-sensitive tests plus strict dashboard and holiday checks.
# -------------------------------------------------------------------
######################################################################
name: Config Validation
on:
pull_request:
push:
branches: ["master"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: config-validation-${{ github.ref }}
cancel-in-progress: true
jobs:
deterministic-validation:
name: Deterministic validation
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
- name: Install validation dependencies
run: python -m pip install --requirement requirements-dev.txt
- name: Run mutation-sensitive validator tests
run: python -m unittest discover -s tests -v
- name: Validate dashboard YAML and include graph
shell: pwsh
run: ./tools/validate_dashboards.ps1
- name: Validate holiday calendars, scenes, and routing
shell: pwsh
run: ./tools/holiday_lighting_coverage.ps1
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Validate browser smoke script syntax
run: node --check tools/ha_ui_smoke.mjs

1
.gitignore vendored

@ -69,6 +69,7 @@ __pycache__
hacs
alexa_media
custom_components
output/playwright/
config/www/community
config/.cache/
community

@ -1,28 +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
# -------------------------------------------------------------------
# Travis CI - legacy Home Assistant configuration validation.
# Defines the Travis build used to validate this repository.
# -------------------------------------------------------------------
######################################################################
language: python
python: "3.8"
before_install:
- touch fake_key.pem
- mv config/travis_secrets.yaml config/secrets.yaml
install:
- pip3 install homeassistant
- pip3 install colorlog
- pip3 install python-dateutil
- git clone https://github.com/custom-components/youtube.git youtube_component/
- mv youtube_component/custom_components config/custom_components
- git clone https://github.com/mrk-its/homeassistant-blitzortung.git blitzortung_component/
- mv blitzortung_component/custom_components config/custom_components
script:
- hass -c config/ --script check_config --info all

@ -0,0 +1 @@
PyYAML==6.0.3

@ -0,0 +1,104 @@
from __future__ import annotations
import shutil
import sys
import tempfile
import unittest
from datetime import date
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
TOOLS = REPO / "tools"
sys.path.insert(0, str(TOOLS))
from holiday_lighting_coverage import CoverageError, validate_coverage # noqa: E402
class HolidayLightingCoverageTests(unittest.TestCase):
def setUp(self) -> None:
self.tempdir = tempfile.TemporaryDirectory(prefix="holiday-coverage-")
self.root = Path(self.tempdir.name)
files = (
"config/www/json_data/holidays.json",
"config/www/json_data/flag_days.json",
"config/scene/monthly_colors.yaml",
"config/packages/holiday.yaml",
"config/script/monthly_color_scene.yaml",
)
for relative in files:
source = REPO / relative
target = self.root / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
def tearDown(self) -> None:
self.tempdir.cleanup()
def _replace(self, relative: str, old: str, new: str, count: int = -1) -> None:
path = self.root / relative
original = path.read_text(encoding="utf-8")
changed = original.replace(old, new, count)
self.assertNotEqual(original, changed, f"mutation target not found: {old}")
path.write_text(changed, encoding="utf-8")
def test_current_contract_passes(self) -> None:
results = validate_coverage(self.root, date(2026, 7, 22), 24)
self.assertEqual(
results["Easter Sunday"],
(date(2027, 3, 28), date(2028, 4, 16)),
)
def test_inverted_scene_state_fails(self) -> None:
self._replace(
"config/scene/monthly_colors.yaml",
"state: 'on'",
"state: 'off'",
1,
)
with self.assertRaisesRegex(CoverageError, "must have state: on"):
validate_coverage(self.root, date(2026, 7, 22), 24)
def test_missing_required_year_fails(self) -> None:
self._replace(
"config/www/json_data/holidays.json",
' "03/28/2027": "Easter Sunday",\n',
"",
1,
)
with self.assertRaisesRegex(CoverageError, "missing 2027-03-28"):
validate_coverage(self.root, date(2026, 7, 22), 24)
def test_wrong_static_holiday_name_fails(self) -> None:
self._replace(
"config/www/json_data/holidays.json",
'"1/1": "New Years Day"',
'"1/1": "Wrong Holiday"',
1,
)
with self.assertRaisesRegex(CoverageError, "New Years Day"):
validate_coverage(self.root, date(2026, 7, 22), 24)
def test_invalid_unrelated_dynamic_date_fails(self) -> None:
self._replace(
"config/www/json_data/holidays.json",
'"01/19/2026": "MLK Day"',
'"2026-01-19": "MLK Day"',
1,
)
with self.assertRaisesRegex(CoverageError, "invalid date key"):
validate_coverage(self.root, date(2026, 7, 22), 24)
def test_scene_routing_typo_fails(self) -> None:
self._replace(
"config/packages/holiday.yaml",
"scene.month_{{ mode }}_colors",
"scene.month_{{ mode }}_colour",
1,
)
with self.assertRaisesRegex(CoverageError, "package routing"):
validate_coverage(self.root, date(2026, 7, 22), 24)
if __name__ == "__main__":
unittest.main()

@ -7,155 +7,26 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$holidayPath = Join-Path $repoRoot 'config/www/json_data/holidays.json'
$flagPath = Join-Path $repoRoot 'config/www/json_data/flag_days.json'
$scenePath = Join-Path $repoRoot 'config/scene/monthly_colors.yaml'
$endDate = $StartDate.Date.AddMonths($Months)
$errors = @()
$python = Get-Command py -ErrorAction SilentlyContinue
$arguments = @()
function Read-JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Missing JSON file: $Path"
}
try {
return Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -AsHashtable
} catch {
throw "Invalid JSON in $Path`: $($_.Exception.Message)"
}
}
function Parse-DateKey {
param(
[Parameter(Mandatory = $true)]
[string]$DateText
)
return [datetime]::Parse(
$DateText,
[Globalization.CultureInfo]::InvariantCulture
).Date
}
function Get-DynamicEvents {
param(
[Parameter(Mandatory = $true)]
[object]$DynamicMap,
[Parameter(Mandatory = $true)]
[string[]]$Names
)
$events = @()
foreach ($dateText in $DynamicMap.Keys) {
$eventDate = Parse-DateKey -DateText ([string]$dateText)
$eventName = [string]$DynamicMap[$dateText]
if ($eventDate -ge $StartDate.Date -and $eventDate -lt $endDate -and $Names -contains $eventName) {
$events += [pscustomobject]@{
Name = $eventName
Date = $eventDate
}
}
}
return $events | Sort-Object Date, Name
}
$holidayJson = Read-JsonFile -Path $holidayPath
$flagJson = Read-JsonFile -Path $flagPath
$holidayData = $holidayJson['MAJOR_US']
$flagData = $flagJson['Flag_Days_US']
$holidayStatic = $holidayData['static']
$holidayDynamic = $holidayData['dynamic']
$flagDynamic = $flagData['dynamic']
$sceneText = Get-Content -Raw -LiteralPath $scenePath
$sceneNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
foreach ($match in [regex]::Matches($sceneText, '(?m)^\s*-\s+name:\s+(month_[A-Za-z0-9_]+_colors)\s*$')) {
[void]$sceneNames.Add($match.Groups[1].Value)
}
$lightingModes = @(
'standard',
'RWB',
'new_years_day',
'valentine',
'mardi_gras',
'pi',
'st_patty',
'easter',
'starwars',
'cinco_de_mayo',
'mothers_day',
'fathers_day',
'halloween',
'veterans',
'thanksgiving',
'hanukkah',
'christmas'
)
foreach ($mode in $lightingModes) {
$sceneName = "month_${mode}_colors"
if (-not $sceneNames.Contains($sceneName)) {
$errors += "Missing scene for lighting mode '$mode': expected $sceneName in $scenePath"
}
if ($python) {
$arguments += '-3'
} else {
$python = Get-Command python -ErrorAction SilentlyContinue
}
$staticRequirements = @(
@{ Key = '1/1'; Name = 'New Years Day' },
@{ Key = '2/14'; Name = 'Valentines Day' },
@{ Key = '3/14'; Name = 'Pi Day' },
@{ Key = '3/17'; Name = 'St. Patricks Day' },
@{ Key = '5/4'; Name = 'Star Wars Day' },
@{ Key = '5/5'; Name = 'Cinco de Mayo' },
@{ Key = '7/4'; Name = 'Independence Day' },
@{ Key = '10/31'; Name = 'Halloween' },
@{ Key = '11/11'; Name = 'Veterans Day' },
@{ Key = '12/25'; Name = 'Christmas Day' },
@{ Key = '12/31'; Name = 'New Years Eve' }
)
foreach ($requirement in $staticRequirements) {
if (-not $holidayStatic.ContainsKey($requirement.Key)) {
$errors += "Missing static holiday key $($requirement.Key) for $($requirement.Name)"
}
if (-not $python) {
throw "Python 3 not found. Install Python 3 and the pinned requirements in requirements-dev.txt."
}
$dynamicRequirements = @(
@{ Name = 'Easter Sunday'; Source = 'holidays'; Map = $holidayDynamic },
@{ Name = 'Mothers Day'; Source = 'holidays'; Map = $holidayDynamic },
@{ Name = 'Fathers Day'; Source = 'holidays'; Map = $holidayDynamic },
@{ Name = 'Thanksgiving Day'; Source = 'holidays'; Map = $holidayDynamic },
@{ Name = 'Memorial Day'; Source = 'flag_days'; Map = $flagDynamic },
@{ Name = 'Labor Day'; Source = 'flag_days'; Map = $flagDynamic }
$arguments += @(
"$repoRoot/tools/holiday_lighting_coverage.py"
'--months'
$Months
'--start-date'
$StartDate.ToString('yyyy-MM-dd')
)
foreach ($requirement in $dynamicRequirements) {
$events = @(Get-DynamicEvents -DynamicMap $requirement.Map -Names @($requirement.Name))
if ($events.Count -eq 0) {
$errors += "No $($requirement.Source) calendar coverage for '$($requirement.Name)' between $($StartDate.ToString('yyyy-MM-dd')) and $($endDate.ToString('yyyy-MM-dd'))"
}
}
Write-Host "Holiday lighting coverage window: $($StartDate.ToString('yyyy-MM-dd')) to $($endDate.ToString('yyyy-MM-dd'))"
Write-Host "JSON: OK - parsed holidays.json and flag_days.json"
Write-Host "Scenes: $($sceneNames.Count) monthly scenes found"
if ($errors.Count -gt 0) {
foreach ($err in $errors) {
Write-Host "ERROR: $err"
}
exit 1
}
foreach ($requirement in $dynamicRequirements) {
$events = @(Get-DynamicEvents -DynamicMap $requirement.Map -Names @($requirement.Name))
$dates = ($events | ForEach-Object { $_.Date.ToString('yyyy-MM-dd') }) -join ', '
Write-Host "$($requirement.Name): $dates"
}
Write-Host "Coverage OK: required lighting scenes and dynamic holiday dates are present for the requested window."
& $python.Source @arguments
exit $LASTEXITCODE

@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Validate holiday calendars, lighting scenes, and scene-routing contracts."""
from __future__ import annotations
import argparse
import calendar
import json
import sys
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from typing import Callable, Iterable
from validate_dashboards import ValidationError as YamlValidationError
from validate_dashboards import parse_yaml
class CoverageError(RuntimeError):
"""Holiday lighting coverage is incomplete or internally inconsistent."""
LIGHTING_MODES = (
"standard",
"RWB",
"new_years_day",
"valentine",
"mardi_gras",
"pi",
"st_patty",
"easter",
"starwars",
"cinco_de_mayo",
"mothers_day",
"fathers_day",
"halloween",
"veterans",
"thanksgiving",
"hanukkah",
"christmas",
)
STATIC_REQUIREMENTS = {
"1/1": "New Years Day",
"2/14": "Valentines Day",
"3/14": "Pi Day",
"3/17": "St. Patricks Day",
"5/4": "Star Wars Day",
"5/5": "Cinco de Mayo",
"7/4": "Independence Day",
"10/31": "Halloween",
"11/11": "Veterans Day",
"12/25": "Christmas Day",
"12/31": "New Years Eve",
}
@dataclass(frozen=True)
class DynamicRule:
name: str
source: str
expected_date: Callable[[int], date]
def gregorian_easter(year: int) -> date:
"""Meeus/Jones/Butcher Gregorian Easter algorithm."""
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
ell = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * ell) // 451
month = (h + ell - 7 * m + 114) // 31
day = ((h + ell - 7 * m + 114) % 31) + 1
return date(year, month, day)
def nth_weekday(year: int, month: int, weekday: int, occurrence: int) -> date:
first = date(year, month, 1)
offset = (weekday - first.weekday()) % 7
return first + timedelta(days=offset + 7 * (occurrence - 1))
def last_weekday(year: int, month: int, weekday: int) -> date:
last = date(year, month, calendar.monthrange(year, month)[1])
return last - timedelta(days=(last.weekday() - weekday) % 7)
DYNAMIC_RULES = (
DynamicRule("Easter Sunday", "holidays", gregorian_easter),
DynamicRule("Mothers Day", "holidays", lambda year: nth_weekday(year, 5, 6, 2)),
DynamicRule("Fathers Day", "holidays", lambda year: nth_weekday(year, 6, 6, 3)),
DynamicRule("Thanksgiving Day", "holidays", lambda year: nth_weekday(year, 11, 3, 4)),
DynamicRule("Memorial Day", "flag_days", lambda year: last_weekday(year, 5, 0)),
DynamicRule("Labor Day", "flag_days", lambda year: nth_weekday(year, 9, 0, 1)),
)
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def add_months(start: date, months: int) -> date:
if months < 1:
raise CoverageError("months must be at least 1")
month_index = start.month - 1 + months
year = start.year + month_index // 12
month = month_index % 12 + 1
day = min(start.day, calendar.monthrange(year, month)[1])
return date(year, month, day)
def _reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise CoverageError(f"duplicate JSON key {key!r}")
result[key] = value
return result
def read_json(path: Path) -> dict[str, object]:
try:
value = json.loads(
path.read_text(encoding="utf-8"),
object_pairs_hook=_reject_duplicate_json_keys,
)
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise CoverageError(f"invalid JSON in {path}: {exc}") from exc
if not isinstance(value, dict):
raise CoverageError(f"JSON root must be a mapping: {path}")
return value
def _require_mapping(value: object, label: str) -> dict[str, object]:
if not isinstance(value, dict):
raise CoverageError(f"{label} must be a mapping")
return value
def parse_calendar_date(value: str, source: str) -> date:
try:
month_text, day_text, year_text = value.split("/")
parsed = date(int(year_text), int(month_text), int(day_text))
except (TypeError, ValueError) as exc:
raise CoverageError(f"invalid date key {value!r} in {source}") from exc
if value != parsed.strftime("%m/%d/%Y"):
raise CoverageError(
f"dynamic date key must use MM/DD/YYYY format in {source}: {value!r}"
)
return parsed
def validate_dynamic_map(dynamic_map: dict[str, object], source: str) -> None:
for key, value in dynamic_map.items():
parse_calendar_date(key, source)
if not isinstance(value, str) or not value.strip():
raise CoverageError(f"dynamic event {key!r} in {source} has no name")
def validate_dynamic_rule(
rule: DynamicRule,
dynamic_map: dict[str, object],
start: date,
end: date,
) -> tuple[date, ...]:
expected = {
expected_date
for year in range(start.year, end.year + 1)
if start <= (expected_date := rule.expected_date(year)) < end
}
actual = {
parse_calendar_date(key, rule.source)
for key, value in dynamic_map.items()
if value == rule.name and start <= parse_calendar_date(key, rule.source) < end
}
if actual != expected:
missing = sorted(expected - actual)
unexpected = sorted(actual - expected)
details: list[str] = []
if missing:
details.append("missing " + ", ".join(day.isoformat() for day in missing))
if unexpected:
details.append("unexpected " + ", ".join(day.isoformat() for day in unexpected))
raise CoverageError(f"{rule.name} coverage mismatch: {'; '.join(details)}")
return tuple(sorted(actual))
def validate_scenes(path: Path) -> tuple[str, ...]:
try:
document = parse_yaml(path)
except YamlValidationError as exc:
raise CoverageError(str(exc)) from exc
if not isinstance(document, list):
raise CoverageError(f"scene file must contain a list: {path}")
scenes: dict[str, dict[str, object]] = {}
for index, scene in enumerate(document):
if not isinstance(scene, dict):
raise CoverageError(f"scene {index} must be a mapping")
name = scene.get("name")
if not isinstance(name, str) or not name:
raise CoverageError(f"scene {index} is missing name")
if name in scenes:
raise CoverageError(f"duplicate scene name {name!r}")
scenes[name] = scene
for mode in LIGHTING_MODES:
scene_name = f"month_{mode}_colors"
scene = scenes.get(scene_name)
if scene is None:
raise CoverageError(f"missing required scene {scene_name}")
entities = scene.get("entities")
if not isinstance(entities, dict) or not entities:
raise CoverageError(f"scene {scene_name} must contain entities")
for entity_id, state_config in entities.items():
if not isinstance(state_config, dict):
raise CoverageError(
f"scene {scene_name} entity {entity_id} must use a state mapping"
)
if state_config.get("state") != "on":
raise CoverageError(
f"scene {scene_name} entity {entity_id} must have state: on"
)
return tuple(sorted(scenes))
def validate_routing_contract(package_path: Path, script_path: Path) -> None:
try:
package_text = package_path.read_text(encoding="utf-8")
script_text = script_path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise CoverageError(f"cannot read holiday routing configuration: {exc}") from exc
required_package_fragments = (
"resource: http://localhost:8123/local/json_data/holidays.json",
"resource: http://localhost:8123/local/json_data/flag_days.json",
"unique_id: holiday_lighting_mode",
"unique_id: holiday_lighting_scene",
"scene.month_{{ mode }}_colors",
"scene.month_standard_colors",
)
for fragment in required_package_fragments:
if fragment not in package_text:
raise CoverageError(f"holiday package routing is missing {fragment!r}")
for mode in LIGHTING_MODES:
if mode == "standard":
continue
assignment = f"mode.value = '{mode}'"
if assignment not in package_text:
raise CoverageError(f"holiday lighting mode is never assigned: {mode}")
required_script_fragments = (
"service: scene.turn_on",
"sensor.holiday_lighting_scene",
"scene.month_standard_colors",
)
for fragment in required_script_fragments:
if fragment not in script_text:
raise CoverageError(f"monthly scene script is missing {fragment!r}")
def validate_coverage(root: Path, start: date, months: int) -> dict[str, tuple[date, ...]]:
root = root.resolve()
end = add_months(start, months)
holiday_path = root / "config" / "www" / "json_data" / "holidays.json"
flag_path = root / "config" / "www" / "json_data" / "flag_days.json"
scene_path = root / "config" / "scene" / "monthly_colors.yaml"
holidays = _require_mapping(read_json(holiday_path).get("MAJOR_US"), "MAJOR_US")
flags = _require_mapping(read_json(flag_path).get("Flag_Days_US"), "Flag_Days_US")
holiday_static = _require_mapping(holidays.get("static"), "MAJOR_US.static")
holiday_dynamic = _require_mapping(holidays.get("dynamic"), "MAJOR_US.dynamic")
flag_dynamic = _require_mapping(flags.get("dynamic"), "Flag_Days_US.dynamic")
validate_dynamic_map(holiday_dynamic, "holidays")
validate_dynamic_map(flag_dynamic, "flag_days")
for key, expected_name in STATIC_REQUIREMENTS.items():
actual_name = holiday_static.get(key)
if actual_name != expected_name:
raise CoverageError(
f"static holiday {key} must be {expected_name!r}, got {actual_name!r}"
)
dates: dict[str, tuple[date, ...]] = {}
for rule in DYNAMIC_RULES:
source_map = holiday_dynamic if rule.source == "holidays" else flag_dynamic
dates[rule.name] = validate_dynamic_rule(rule, source_map, start, end)
validate_scenes(scene_path)
validate_routing_contract(
root / "config" / "packages" / "holiday.yaml",
root / "config" / "script" / "monthly_color_scene.yaml",
)
return dates
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=repo_root())
parser.add_argument("--months", type=int, default=24)
parser.add_argument("--start-date", type=date.fromisoformat, default=date.today())
return parser
def main(argv: Iterable[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
results = validate_coverage(args.root, args.start_date, args.months)
end = add_months(args.start_date, args.months)
except CoverageError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
print(f"Holiday lighting coverage window: {args.start_date} to {end}")
for name, dates in results.items():
print(f"{name}: {', '.join(day.isoformat() for day in dates)}")
print(
"Coverage OK: exact per-year calendar dates, scene behavior, and routing contracts validated."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Loading…
Cancel
Save

Powered by TurnKey Linux.