chore: drop HACS distribution and move CI to Gitea
CI / Validate integration (pull_request) Successful in 40s
CI / Lint (pull_request) Successful in 49s

The GitHub mirror is gone, so the HACS packaging and the GitHub-only CI
no longer have anything to run against.

Remove hacs.json and the .github/ workflow. Both of its jobs (hassfest
and hacs/action) were gated on `github.server_url == 'https://github.com'`
and are GitHub-hosted actions, so neither can run on the Gitea runner.

Replace them with .gitea/workflows/ci.yml:
  - lint: ruff check + ruff format --check, version pinned because ruff's
    default rule set and formatter output move between releases
  - validate: byte-compile, then scripts/validate_integration.py, which
    covers the part of hassfest that matters for a manually installed
    custom component (manifest keys, domain/directory agreement, and
    translations matching strings.json key for key)

README now documents manual installation only, and points at the local
CI commands. manifest.json documentation and issue_tracker point at the
Gitea repo; NOTICE keeps its upstream attribution.

Adopting ruff surfaced two real defects, fixed here:
  - climate.async_set_hvac_mode raised HomeAssistantError for an
    unsupported mode from inside a try that catches Exception, so the
    message was re-wrapped as "Failed to set HVAC mode: Unsupported HVAC
    mode: ...". The validation is now hoisted above the try.
  - config_flow.async_step_reauth_confirm swallowed unexpected exceptions
    into a bare "unknown" error with no log, unlike the user step. It now
    logs via _LOGGER.exception.

Remaining changes are mechanical: import ordering, docstrings on public
methods, ClassVar on mutable class attributes, contextlib.suppress, and
asyncio.TimeoutError -> TimeoutError (an alias since 3.11). The smoke
test now reads the new DknCloudNaClient.socket_connected property rather
than reaching into _socket.

Co-authored-by: anthropic/claude-opus-5
This commit is contained in:
2026-09-19 10:09:11 -03:00
co-authored by anthropic/claude-opus-5
parent d6e935e8d7
commit 706580fab2
17 changed files with 461 additions and 99 deletions
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Validate the integration's static metadata.
This replaces the parts of Home Assistant's `hassfest` action that matter for a
manually installed custom component. hassfest itself is a GitHub-hosted action
and cannot run on the Gitea runner, and it also enforces rules that only apply
to integrations vendored into HA core.
Checks performed:
* every JSON file in the component parses
* manifest.json has the keys HA requires of a custom integration, with sane
values (domain matches the directory, version is present, etc.)
* every translation file has exactly the same key structure as strings.json,
so a missing translation shows up here rather than as a blank label in the
UI
Exits non-zero with one line per problem.
"""
from __future__ import annotations
import json
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parent.parent
COMPONENTS_DIR = REPO_ROOT / "custom_components"
# Keys HA reads from a custom integration's manifest. `version` is required for
# custom components specifically (core integrations must omit it).
REQUIRED_MANIFEST_KEYS = (
"domain",
"name",
"codeowners",
"documentation",
"iot_class",
"version",
)
VALID_IOT_CLASSES = {
"assumed_state",
"cloud_polling",
"cloud_push",
"local_polling",
"local_push",
"calculated",
}
VALID_INTEGRATION_TYPES = {
"device",
"entity",
"hardware",
"helper",
"hub",
"service",
"system",
"virtual",
}
def key_structure(value: object, prefix: str = "") -> set[str]:
"""Flatten a nested dict into dotted key paths, ignoring leaf values."""
if not isinstance(value, dict):
return {prefix}
paths: set[str] = set()
for key, child in value.items():
paths |= key_structure(child, f"{prefix}.{key}" if prefix else key)
return paths
def check_json_parses(errors: list[str]) -> dict[Path, object]:
"""Parse every JSON file under custom_components/, recording failures."""
parsed: dict[Path, object] = {}
for path in sorted(COMPONENTS_DIR.rglob("*.json")):
try:
parsed[path] = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as err:
errors.append(f"{path.relative_to(REPO_ROOT)}: invalid JSON: {err}")
return parsed
def check_manifest(component: Path, manifest: object, errors: list[str]) -> None:
"""Validate a single manifest.json payload."""
rel = (component / "manifest.json").relative_to(REPO_ROOT)
if not isinstance(manifest, dict):
errors.append(f"{rel}: expected a JSON object")
return
errors.extend(
f"{rel}: missing required key '{key}'"
for key in REQUIRED_MANIFEST_KEYS
if key not in manifest
)
if manifest.get("domain") != component.name:
errors.append(
f"{rel}: domain '{manifest.get('domain')}' does not match "
f"directory name '{component.name}'"
)
iot_class = manifest.get("iot_class")
if iot_class is not None and iot_class not in VALID_IOT_CLASSES:
errors.append(f"{rel}: unknown iot_class '{iot_class}'")
integration_type = manifest.get("integration_type")
if integration_type is not None and integration_type not in VALID_INTEGRATION_TYPES:
errors.append(f"{rel}: unknown integration_type '{integration_type}'")
codeowners = manifest.get("codeowners")
if not isinstance(codeowners, list):
errors.append(f"{rel}: codeowners must be a list")
else:
errors.extend(
f"{rel}: codeowner '{owner}' must start with '@'"
for owner in codeowners
if not isinstance(owner, str) or not owner.startswith("@")
)
# config_flow: true requires strings.json to describe the flow, otherwise
# the user sees untranslated keys.
if manifest.get("config_flow") and not (component / "strings.json").is_file():
errors.append(f"{rel}: config_flow is true but strings.json is missing")
def check_translations(
component: Path, parsed: dict[Path, object], errors: list[str]
) -> None:
"""Ensure each translation file mirrors strings.json exactly."""
strings_path = component / "strings.json"
if strings_path not in parsed:
return
expected = key_structure(parsed[strings_path])
translations_dir = component / "translations"
if not translations_dir.is_dir():
errors.append(
f"{component.relative_to(REPO_ROOT)}: strings.json exists but "
"translations/ is missing"
)
return
for path in sorted(translations_dir.glob("*.json")):
if path not in parsed:
continue
rel = path.relative_to(REPO_ROOT)
actual = key_structure(parsed[path])
errors.extend(
f"{rel}: missing key '{key}'" for key in sorted(expected - actual)
)
errors.extend(
f"{rel}: key '{key}' not present in strings.json"
for key in sorted(actual - expected)
)
def main() -> int:
"""Run every check and report the problems found."""
errors: list[str] = []
parsed = check_json_parses(errors)
components = sorted(p for p in COMPONENTS_DIR.iterdir() if p.is_dir())
if not components:
errors.append("custom_components/ contains no integration directory")
for component in components:
manifest_path = component / "manifest.json"
if manifest_path not in parsed:
if not manifest_path.is_file():
errors.append(
f"{component.relative_to(REPO_ROOT)}: manifest.json is missing"
)
continue
check_manifest(component, parsed[manifest_path], errors)
check_translations(component, parsed, errors)
if errors:
for error in errors:
print(f"error: {error}", file=sys.stderr)
print(f"\n{len(errors)} problem(s) found.", file=sys.stderr)
return 1
print(
f"OK: validated {len(components)} integration(s), {len(parsed)} JSON file(s)."
)
return 0
if __name__ == "__main__":
sys.exit(main())