From 4277c1ac048b0cc5288dbd914663e57e8367093c Mon Sep 17 00:00:00 2001 From: dkwolf1 <102389032+dkwolf1@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:49:06 +0200 Subject: [PATCH] Add reference checks and native Repairs for Hass-Cleaner 1.1.0 --- .github/workflows/ci.yml | 5 +- .gitignore | 2 + README.md | 7 +- custom_components/hass_cleaner/__init__.py | 144 ++++++++++++++ custom_components/hass_cleaner/config_flow.py | 14 ++ custom_components/hass_cleaner/manifest.json | 13 ++ custom_components/hass_cleaner/references.py | 168 +++++++++++++++++ custom_components/hass_cleaner/sources.py | 90 +++++++++ custom_components/hass_cleaner/strings.json | 19 ++ .../hass_cleaner/translations/en.json | 19 ++ .../hass_cleaner/translations/nl.json | 19 ++ docs/reference-checks.md | 110 +++++++++++ hass_cleaner/CHANGELOG.md | 14 ++ hass_cleaner/DOCS.md | 2 + hass_cleaner/config.yaml | 2 +- hass_cleaner/hass_cleaner/__init__.py | 2 +- hass_cleaner/hass_cleaner/export_text.py | 4 + hass_cleaner/hass_cleaner/plans.py | 6 + hass_cleaner/hass_cleaner/references.py | 124 ++++++++++++ hass_cleaner/hass_cleaner/registry_audit.py | 7 +- hass_cleaner/hass_cleaner/registry_cleanup.py | 2 + hass_cleaner/hass_cleaner/reporting.py | 13 +- hass_cleaner/hass_cleaner/server.py | 24 ++- hass_cleaner/tests/test_companion.py | 155 +++++++++++++++ hass_cleaner/tests/test_reference_sources.py | 107 +++++++++++ hass_cleaner/tests/test_references.py | 178 ++++++++++++++++++ hass_cleaner/tests/test_reporting.py | 8 +- hass_cleaner/tests/test_server.py | 24 ++- hass_cleaner/web/assets/app.js | 101 ++++++++++ hass_cleaner/web/assets/styles.css | 10 + hass_cleaner/web/index.html | 8 +- hass_cleaner/web/tests/references.test.cjs | 61 ++++++ requirements-test.txt | 4 + tools/package_companion.py | 25 +++ 34 files changed, 1474 insertions(+), 17 deletions(-) create mode 100644 custom_components/hass_cleaner/__init__.py create mode 100644 custom_components/hass_cleaner/config_flow.py create mode 100644 custom_components/hass_cleaner/manifest.json create mode 100644 custom_components/hass_cleaner/references.py create mode 100644 custom_components/hass_cleaner/sources.py create mode 100644 custom_components/hass_cleaner/strings.json create mode 100644 custom_components/hass_cleaner/translations/en.json create mode 100644 custom_components/hass_cleaner/translations/nl.json create mode 100644 docs/reference-checks.md create mode 100644 hass_cleaner/hass_cleaner/references.py create mode 100644 hass_cleaner/tests/test_companion.py create mode 100644 hass_cleaner/tests/test_reference_sources.py create mode 100644 hass_cleaner/tests/test_references.py create mode 100644 hass_cleaner/web/tests/references.test.cjs create mode 100644 requirements-test.txt create mode 100644 tools/package_companion.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 200b56b..87000e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,12 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" - - run: pip install -r requirements.txt + - run: pip install -r ../requirements-test.txt - run: python -m unittest discover -s tests -v - run: node --check web/assets/app.js - run: node --check web/assets/i18n.js - run: node web/tests/i18n.test.cjs + - run: node web/tests/references.test.cjs + - run: python ../tools/package_companion.py - run: python -m compileall -q hass_cleaner tests + - run: python -m compileall -q ../custom_components/hass_cleaner diff --git a/.gitignore b/.gitignore index 14ee784..15c135c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ __pycache__/ *.py[cod] .venv/ +.venv-reference-tests/ +dist/ venv/ .mypy_cache/ .ruff_cache/ diff --git a/README.md b/README.md index 260a7b6..e4ec774 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Hass-Cleaner is a Home Assistant App for inspecting storage, stale entities and ## English +**Development preview: 1.1.0.** This checkout adds [reference checks and native Repairs](docs/reference-checks.md) through an optional, separately installed **Hass-Cleaner Companion** integration. This is not a published release or a claim of complete reference coverage. Live Home Assistant acceptance testing is still required. + > [!WARNING] > **Test version — handle with care.** Hass-Cleaner can modify files, Home Assistant registry objects and Recorder data. Review every selection and preferably create a full Home Assistant backup first. Test on a non-critical installation where possible. Use is at your own risk; quarantine and recovery safeguards reduce risk, but cannot guarantee that every integration or user configuration remains unaffected. @@ -22,6 +24,7 @@ A finding is never treated as deletion evidence by itself. The App explains why - Safe storage scanning with one clear export dialog: a readable Markdown report, CSV for spreadsheet analysis and JSON for technical analysis. - Beginner-friendly cleanup categories and a clear **Prepare cleanup** action. - Entity filters for state, duration, integration, device and area. +- Optional companion: static references in automations, scripts, dashboards, scenes, groups, supported helpers/templates and energy/statistics configuration; native Repairs for missing targets and dependency context before registry cleanup. Coverage limits are explicit. - Device and entity grouping by integration or device. - Scan differences for new, changed, recovered and disappeared signals. - Official Home Assistant Recorder purge with separate confirmation. @@ -77,7 +80,7 @@ For version-specific changes, see [CHANGELOG.md](hass_cleaner/CHANGELOG.md). For 4. Start the App and open its web interface. 5. Run a scan first and review the report before preparing an action. -GitHub Actions builds version 1.0.2 for `amd64` and `aarch64`. After the container is published, Home Assistant can install or update the release through this repository. +GitHub Actions builds the version in `hass_cleaner/config.yaml` for `amd64` and `aarch64`. After the container is published, Home Assistant can install or update that version through this repository. The companion is a separate integration, not part of the app container. ### Local development and tests @@ -170,7 +173,7 @@ Versiegebonden wijzigingen staan in [CHANGELOG.md](hass_cleaner/CHANGELOG.md). G 4. Start de App en open de webinterface. 5. Voer eerst een scan uit en beoordeel het rapport voordat je een actie voorbereidt. -GitHub Actions bouwt versie 1.0.2 voor `amd64` en `aarch64`. Na publicatie van de container kan Home Assistant de release via deze repository installeren of bijwerken. +GitHub Actions bouwt de versie uit `hass_cleaner/config.yaml` voor `amd64` en `aarch64`. Na publicatie van de container kan Home Assistant die versie installeren of bijwerken. De ontwikkelversie 1.1.0 bevat [referentiecontrole en Reparaties](docs/reference-checks.md#nederlands-kort) via een apart te installeren companion-integratie. Praktijktests zijn nog nodig; de companion zit niet in de app-container. ### Lokaal ontwikkelen en testen diff --git a/custom_components/hass_cleaner/__init__.py b/custom_components/hass_cleaner/__init__.py new file mode 100644 index 0000000..80365b1 --- /dev/null +++ b/custom_components/hass_cleaner/__init__.py @@ -0,0 +1,144 @@ +"""Read-only reference checks and native Home Assistant Repairs.""" +from __future__ import annotations + +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol +from homeassistant.components import websocket_api +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import callback +from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er, issue_registry as ir +from homeassistant.helpers.event import async_track_time_interval + +from .references import analyze, repair_groups +from .sources import collect_extended_sources, statistic_ids + +DOMAIN = "hass_cleaner" +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + websocket_api.async_register_command(hass, websocket_references) + return True + + +@websocket_api.websocket_command({vol.Required("type"): "hass_cleaner/references", vol.Optional("refresh", default=False): bool}) +@websocket_api.async_response +async def websocket_references(hass, connection, msg): + if not connection.user or not connection.user.is_admin: + connection.send_error(msg["id"], "unauthorized", "Administrator access required") + return + monitor = hass.data.get(DOMAIN) + if monitor is None: + connection.send_error(msg["id"], "not_loaded", "Set up Hass-Cleaner Companion first") + return + if msg["refresh"] or monitor.report is None: + await monitor.refresh() + connection.send_result(msg["id"], monitor.report) + + +async def collect_sources(hass): + """Keep access to HA runtime configuration isolated in this adapter.""" + sources = [] + for kind in ("automation", "script"): + component = hass.data.get(kind) + if component is None: + if kind in hass.config.components: + sources.append({"id": kind, "kind": kind, "name": kind, "error": "Configuration component unavailable"}) + continue + for entity in list(component.entities): + sources.append({"id": entity.entity_id, "kind": kind, "name": entity.name or entity.entity_id, + "config": getattr(entity, "raw_config", None)}) + lovelace = hass.data.get("lovelace") + dashboards = getattr(lovelace, "dashboards", None) + if lovelace is not None and dashboards is None: + sources.append({"id": "dashboard:all", "kind": "dashboard", "name": "Dashboards", "error": "Dashboard API unavailable"}) + for url_path, dashboard in list((dashboards or {}).items()): + source = {"id": f"dashboard:{url_path if url_path is not None else 'default'}", "kind": "dashboard", + "name": (dashboard.config or {}).get("title", url_path or "Overview")} + try: + source["config"] = await dashboard.async_load(False) + except Exception as exc: + # Do not log raw exception messages: they can include configuration values. + source["error"] = f"Dashboard could not be loaded ({type(exc).__name__})" + sources.append(source) + sources.extend(await collect_extended_sources(hass)) + return sources + + +class ReferenceMonitor: + def __init__(self, hass): + self.hass = hass + self.report = None + self.lock = asyncio.Lock() + self.closed = False + + async def refresh(self, _now=None): + async with self.lock: + if self.closed: + return + hass = self.hass + if not hass.is_running: + self.report = {"schema_version": 1, "status": "starting", "summary": {}, "sources": [], "references": []} + return + try: + sources = await collect_sources(hass) + known = {"entity": set(er.async_get(hass).entities) | set(hass.states.async_entity_ids()), + "entity_aliases": {e.id: e.entity_id for e in er.async_get(hass).entities.values()}, + "device": set(dr.async_get(hass).devices), "area": set(ar.async_get(hass).areas), + "action": {f"{domain}.{service}" for domain, services in hass.services.async_services().items() for service in services}} + known["statistic"] = await statistic_ids(hass) + report = await hass.async_add_executor_job(analyze, sources, known) + if self.closed: + return + self.reconcile(report) + self.report = report + except Exception as exc: + if not self.report or self.report.get("status") != "unavailable": + _LOGGER.warning("Reference check could not complete (%s)", type(exc).__name__) + self.report = {"schema_version": 1, "status": "unavailable", "summary": {}, "sources": [], "references": [], + "error": "Reference check failed; previous repair issues have been retained"} + + @callback + def reconcile(self, report): + groups = repair_groups(report) + registry = ir.async_get(self.hass) + # A failed/partial source must not falsely resolve its existing issue. + complete = {s["id"] for s in report["sources"] if s["status"] == "checked"} + seen = {s["id"] for s in report["sources"]} + for (domain, issue_id), issue in list(registry.issues.items()): + if domain != DOMAIN or not issue_id.startswith("reference_") or issue_id in groups: + continue + source_id = (issue.data or {}).get("source_id") + if source_id in complete or (source_id not in seen and report["status"] == "completed"): + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + for issue_id, group in groups.items(): + details = "\n".join("- " + item for item in group["items"][:30]) + ir.async_create_issue(self.hass, DOMAIN, issue_id, is_fixable=False, is_persistent=False, + severity=ir.IssueSeverity.ERROR, translation_key="missing_references", + translation_placeholders={"source": group["source"], "references": details, "count": str(len(group["items"]))}, + data={"source_id": group["source_id"]}, + learn_more_url="https://github.com/dkwolf1/Hass-Cleaner/blob/main/docs/reference-checks.md") + + +async def async_setup_entry(hass, entry): + monitor = hass.data[DOMAIN] = ReferenceMonitor(hass) + entry.async_on_unload(async_track_time_interval(hass, monitor.refresh, timedelta(minutes=5))) + if hass.is_running: + await monitor.refresh() + else: + async def started(_event): + await monitor.refresh() + entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, started)) + return True + + +async def async_unload_entry(hass, entry): + monitor = hass.data.pop(DOMAIN) + monitor.closed = True + for domain, issue_id in list(ir.async_get(hass).issues): + if domain == DOMAIN and issue_id.startswith("reference_"): + ir.async_delete_issue(hass, DOMAIN, issue_id) + return True diff --git a/custom_components/hass_cleaner/config_flow.py b/custom_components/hass_cleaner/config_flow.py new file mode 100644 index 0000000..11d4c19 --- /dev/null +++ b/custom_components/hass_cleaner/config_flow.py @@ -0,0 +1,14 @@ +"""Single-instance setup for reference checks and Repairs.""" +import voluptuous as vol +from homeassistant.config_entries import ConfigFlow + + +class HassCleanerConfigFlow(ConfigFlow, domain="hass_cleaner"): + VERSION = 1 + + async def async_step_user(self, user_input=None): + await self.async_set_unique_id("hass_cleaner_companion") + self._abort_if_unique_id_configured() + if user_input is not None: + return self.async_create_entry(title="Hass-Cleaner Companion", data={}) + return self.async_show_form(step_id="user", data_schema=vol.Schema({})) diff --git a/custom_components/hass_cleaner/manifest.json b/custom_components/hass_cleaner/manifest.json new file mode 100644 index 0000000..4db6ee4 --- /dev/null +++ b/custom_components/hass_cleaner/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "hass_cleaner", + "name": "Hass-Cleaner Companion", + "codeowners": ["@dkwolf1"], + "config_flow": true, + "dependencies": ["websocket_api"], + "documentation": "https://github.com/dkwolf1/Hass-Cleaner/blob/main/docs/reference-checks.md", + "integration_type": "service", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/dkwolf1/Hass-Cleaner/issues", + "requirements": [], + "version": "1.1.0" +} diff --git a/custom_components/hass_cleaner/references.py b/custom_components/hass_cleaner/references.py new file mode 100644 index 0000000..dad2897 --- /dev/null +++ b/custom_components/hass_cleaner/references.py @@ -0,0 +1,168 @@ +"""Static reference analysis. Never evaluate templates or expose config values.""" +from __future__ import annotations + +import hashlib +import re +from datetime import datetime, timezone + +ENTITY = re.compile(r"[a-z_][a-z0-9_]*\.[a-z0-9_]+\Z") +DEVICE = re.compile(r"[a-f0-9]{32}\Z") +KEY_TYPES = {"entity_id": "entity", "entity": "entity", "device_id": "device", "area_id": "area"} +HELPER_ENTITY_KEYS = {"source", "source_entity", "source_entity_id", "entity_ids", "entities", "sensor", "heater", + "target_sensor", "humidity_sensor", "humidifier", "cooler", "switch", "input_entity"} +STATISTIC_KEYS = {"statistic_id", "statistic_ids", "stat_energy_from", "stat_energy_to", "stat_cost", + "stat_compensation", "stat_consumption", "included_in_stat", "stat_rate", "stat_rate_inverted", + "stat_rate_from", "stat_rate_to", "stat_soc"} + + +def template_entities(value): + """Inspect syntax only; quoted examples/comments are not dependencies.""" + from jinja2 import Environment, nodes, TemplateSyntaxError + try: + tree = Environment().parse(value) + except TemplateSyntaxError: + return set() + result = set() + for call in tree.find_all(nodes.Call): + if (isinstance(call.node, nodes.Name) + and call.node.name in {"states", "is_state", "is_state_attr", "state_attr", "expand"}): + for arg in call.args[:1] if call.node.name != "expand" else call.args: + if isinstance(arg, nodes.Const) and isinstance(arg.value, str) and ENTITY.fullmatch(arg.value): + result.add(arg.value) + for attr in tree.find_all(nodes.Getattr): + if (isinstance(attr.node, nodes.Getattr) and isinstance(attr.node.node, nodes.Name) + and attr.node.node.name == "states"): + target = f"{attr.node.attr}.{attr.attr}" + if ENTITY.fullmatch(target): + result.add(target) + return result + + +def analyze(sources, known): + """Sources are {id, kind, name, config, error?}; known contains sets of IDs.""" + references, coverage = [], [] + for source in sources: + record = {key: str(source.get(key, "")) for key in ("id", "kind", "name")} + record.update(dynamic=0, custom_cards=0, status="partial" if source.get("partial") else "checked") + found = set() + visited = 0 + + def add(kind, target, location): + if not isinstance(target, str): + return + if target in {"all", "none", "", "this.entity_id"}: + return + if kind == "entity" and DEVICE.fullmatch(target): + # Device automations can store the entity registry UUID instead + # of the public entity_id. Resolve known UUIDs for inbound use. + target = known.get("entity_aliases", {}).get(target, target) + if DEVICE.fullmatch(target): + kind = "entity_registry" + if kind in {"entity", "action"} and not ENTITY.fullmatch(target): + if kind == "entity" or "." in target: + record["status"] = "partial" + return + if kind == "device" and not DEVICE.fullmatch(target): + record["dynamic"] += 1 + return + if kind == "statistic" and not re.fullmatch(r"[a-z_][a-z0-9_]*[.:][A-Za-z0-9_.:-]+", target): + record["status"] = "partial" + return + key = (kind, target, location) + if key in found: + return + found.add(key) + inventory = known.get(kind, set()) + if inventory is None: + record["status"] = "partial" + references.append({"source_id": record["id"], "source_kind": record["kind"], + "source_name": record["name"], "target_type": kind, "target_id": target, + "location": location, "missing": inventory is not None and target not in inventory, + "verification": "unavailable" if inventory is None else "checked"}) + + def walk(value, location="$", key="", depth=0, statistic_context=False): + nonlocal visited + visited += 1 + if visited > 100000 or depth > 60: + record["status"] = "partial" + return + if isinstance(value, dict): + statistic_context = statistic_context or (record["kind"] == "dashboard" and value.get("type") in {"statistic", "statistics-graph"}) + if key == "entities" and record["kind"] == "scene": + for entity_id in value: + add("entity", entity_id, f"{location}[{entity_id}]") + return + if str(value.get("type", "")).startswith("custom:"): + record["custom_cards"] += 1 + if any(key in value for key in ("use_blueprint", "strategy", "label_id", "floor_id", "entity_globs", "domains")): + record["status"] = "partial" + for child_key, child in value.items(): + if visited > 100000: + break + # Keys, not contents: output never contains credentials or template text. + walk(child, f"{location}.{child_key}", str(child_key), depth + 1, statistic_context) + elif isinstance(value, (list, tuple)): + for index, child in enumerate(value): + if visited > 100000: + break + walk(child, f"{location}[{index}]", key, depth + 1, statistic_context) + else: + # HA validated configurations can hold Template objects. + value = getattr(value, "template", value) + if not isinstance(value, str): + return + if "{{" in value or "{%" in value: + if not (record["kind"] in {"automation", "script", "template", "dashboard"} or key.endswith("_template")): + if key in KEY_TYPES or key in HELPER_ENTITY_KEYS or key in STATISTIC_KEYS: + record["status"] = "partial" + return + record["dynamic"] += 1 + if len(value) > 65536: + return + for target in template_entities(value): + add("entity", target, location) + return + if statistic_context and key in {"entity", "entities"}: + add("statistic", value, location) + elif key in KEY_TYPES: + add(KEY_TYPES[key], value, location) + elif key in STATISTIC_KEYS: + add("statistic", value, location) + elif record["kind"] == "energy" and key in {"entity_energy_price", "entity_energy_price_export"}: + add("entity", value, location) + elif record["kind"] in {"helper", "group", "template", "statistics"} and key in HELPER_ENTITY_KEYS: + add("entity", value, location) + elif key == "entities" and record["kind"] == "dashboard": + add("entity", value, location) + elif key in {"service", "action"}: + add("action", value, location) + + if source.get("error") or not isinstance(source.get("config"), dict): + record.update(status="unavailable", error=str(source.get("error") or "Configuration is unavailable")) + else: + walk(source["config"]) + if record["dynamic"] or record["custom_cards"]: + record["status"] = "partial" + coverage.append(record) + references.sort(key=lambda r: (r["source_id"], r["location"], r["target_id"])) + missing = [item for item in references if item["missing"]] + return {"schema_version": 1, "status": "partial" if any(s["status"] != "checked" for s in coverage) else "completed", + "checked_at": datetime.now(timezone.utc).isoformat(), "sources": coverage, "references": references, + "summary": {"sources": len(coverage), "references": len(references), "missing": len(missing), + "partial_sources": sum(s["status"] != "checked" for s in coverage)}, + "limitations": ["Static references in automations, scripts, dashboards, scenes, groups, supported helpers, templates and energy/statistics configuration.", + "Dynamic templates, blueprint inputs, custom cards and sources that failed to load may hide dependencies.", + "No references found does not establish that removal is safe."]} + + +def repair_groups(report): + """Stable issue per source: changing a target must not reset ignored issues.""" + groups = {} + for reference in report.get("references", []): + if not reference["missing"]: + continue + source_id = reference["source_id"] + issue_id = "reference_" + hashlib.sha256(source_id.encode()).hexdigest()[:24] + group = groups.setdefault(issue_id, {"source_id": source_id, "source": reference["source_name"], "items": []}) + group["items"].append(f"{reference['target_type']} {reference['target_id']} — {reference['location']}") + return groups diff --git a/custom_components/hass_cleaner/sources.py b/custom_components/hass_cleaner/sources.py new file mode 100644 index 0000000..d6062f3 --- /dev/null +++ b/custom_components/hass_cleaner/sources.py @@ -0,0 +1,90 @@ +"""Read-only adapters for the additional reference source families.""" +HELPERS = frozenset({"group", "template", "min_max", "integration", "utility_meter", "statistics", + "derivative", "threshold", "filter", "history_stats", "generic_thermostat", + "generic_hygrostat", "switch_as_x", "tod", "input_boolean", "input_number", + "input_select", "input_text", "input_datetime", "input_button", "counter", + "timer", "schedule", "random", "trend", "bayesian"}) + + +def yaml_sources(config): + """HA's loader resolves includes/packages; never return unrelated configuration.""" + result = [] + for section, value in config.items(): + domain = str(section).split(" ", 1)[0] + if domain == "recorder" and isinstance(value, dict): + result.append({"id": f"yaml:{section}", "kind": "statistics", "name": "Recorder entity filters", + "config": {k: value[k] for k in ("include", "exclude") if k in value}, "origin": "yaml"}) + elif domain == "group" and isinstance(value, dict): + for name, group in value.items(): + result.append({"id": f"group.{name}", "kind": "group", "name": str(name), + "config": {"entities": group} if isinstance(group, list) else group, "origin": "yaml"}) + elif domain in HELPERS: + kind = "template" if domain == "template" else "group" if domain == "group" else "helper" + entries = value if isinstance(value, list) else [value] + for index, entry in enumerate(entries): + result.append({"id": f"yaml:{section}:{index}", "kind": kind, + "name": f"YAML {section} [{index}]", "config": entry, + "origin": "yaml"}) + elif domain in {"sensor", "binary_sensor", "light", "switch", "cover", "fan", "climate"}: + entries = value if isinstance(value, list) else [value] + for index, entry in enumerate(entries): + if isinstance(entry, dict) and entry.get("platform") in HELPERS: + platform = entry["platform"] + result.append({"id": f"yaml:{section}:{index}", + "kind": "template" if platform == "template" else "group" if platform == "group" else "helper", + "name": str(entry.get("name") or f"YAML {section} [{index}]"), "config": entry, + "origin": "yaml"}) + return result + + +async def collect_extended_sources(hass): + result = [] + # Runtime scene/group memberships also cover dynamically created scenes. + for state in hass.states.async_all(): + domain = state.entity_id.split(".", 1)[0] + if domain in {"scene", "group"}: + members = state.attributes.get("entity_id") + result.append({"id": state.entity_id, "name": state.name, "kind": domain, + "config": {"entity_id": members} if isinstance(members, (list, tuple)) else None}) + for entry in hass.config_entries.async_entries(): + if entry.domain not in HELPERS: + continue + kind = "template" if entry.domain == "template" else "group" if entry.domain == "group" else "helper" + # Keep exact data/options paths but discard options-shadowed data values. + result.append({"id": f"helper:{entry.entry_id}", "name": entry.title, "kind": kind, + "config": {"data": {k: v for k, v in entry.data.items() if k not in entry.options}, + "options": dict(entry.options)}}) + try: + from homeassistant.config import async_hass_config_yaml + saved = yaml_sources(await async_hass_config_yaml(hass)) + saved_ids = {s["id"] for s in saved} + result = [s for s in result if s["id"] not in saved_ids] + saved + except Exception as exc: + for existing in result: + if existing["kind"] == "group" and existing["id"].startswith("group."): + existing["partial"] = True + result.append({"id": "yaml:coverage", "name": "YAML helpers and templates", "kind": "helper", + "error": f"YAML configuration unavailable ({type(exc).__name__})"}) + if "energy" in hass.config.components: + try: + from homeassistant.components.energy.data import async_get_manager + manager = await async_get_manager(hass) + result.append({"id": "energy:preferences", "name": "Energy configuration", "kind": "energy", + "config": manager.data or {}}) + except Exception as exc: + result.append({"id": "energy:preferences", "name": "Energy configuration", "kind": "energy", + "error": f"Energy configuration unavailable ({type(exc).__name__})"}) + return result + + +async def statistic_ids(hass): + """None means verification unavailable, never an empty successful inventory.""" + if "recorder" not in hass.config.components: + return None + try: + from homeassistant.components.recorder import get_instance + from homeassistant.components.recorder.statistics import list_statistic_ids + metadata = await get_instance(hass).async_add_executor_job(list_statistic_ids, hass) + return {item["statistic_id"] for item in metadata} + except Exception: + return None diff --git a/custom_components/hass_cleaner/strings.json b/custom_components/hass_cleaner/strings.json new file mode 100644 index 0000000..635d977 --- /dev/null +++ b/custom_components/hass_cleaner/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Hass-Cleaner Companion", + "description": "Check automations, scripts, dashboards, scenes, groups, supported helpers, templates and energy/statistics configuration every five minutes. Missing static references appear in Repairs. Configuration is not changed; only this integration's repair issues are managed." + } + }, + "abort": { + "already_configured": "Hass-Cleaner Companion is already configured." + } + }, + "issues": { + "missing_references": { + "title": "Missing references in {source}", + "description": "Hass-Cleaner found {count} missing static references in {source}.\n\n{references}\n\nOpen the named source in Home Assistant or its YAML configuration section and review these paths (array indexes start at 0). Correct the target or restore the missing entity, device, area, action or statistic. Do not delete historical statistics to address this issue. Reload YAML changes where needed. The companion checks again within five minutes.\n\nNothing was changed automatically. Dynamic templates, blueprints and custom cards may hide additional dependencies. A partially checked source may retain its previous issue. Ignore intentional references using Repairs. For more than 30 findings, the complete list is available in the app and exports." + } + } +} diff --git a/custom_components/hass_cleaner/translations/en.json b/custom_components/hass_cleaner/translations/en.json new file mode 100644 index 0000000..635d977 --- /dev/null +++ b/custom_components/hass_cleaner/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Hass-Cleaner Companion", + "description": "Check automations, scripts, dashboards, scenes, groups, supported helpers, templates and energy/statistics configuration every five minutes. Missing static references appear in Repairs. Configuration is not changed; only this integration's repair issues are managed." + } + }, + "abort": { + "already_configured": "Hass-Cleaner Companion is already configured." + } + }, + "issues": { + "missing_references": { + "title": "Missing references in {source}", + "description": "Hass-Cleaner found {count} missing static references in {source}.\n\n{references}\n\nOpen the named source in Home Assistant or its YAML configuration section and review these paths (array indexes start at 0). Correct the target or restore the missing entity, device, area, action or statistic. Do not delete historical statistics to address this issue. Reload YAML changes where needed. The companion checks again within five minutes.\n\nNothing was changed automatically. Dynamic templates, blueprints and custom cards may hide additional dependencies. A partially checked source may retain its previous issue. Ignore intentional references using Repairs. For more than 30 findings, the complete list is available in the app and exports." + } + } +} diff --git a/custom_components/hass_cleaner/translations/nl.json b/custom_components/hass_cleaner/translations/nl.json new file mode 100644 index 0000000..f4fdf91 --- /dev/null +++ b/custom_components/hass_cleaner/translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Hass-Cleaner Companion", + "description": "Controleer automatiseringen, scripts, dashboards, scènes, groepen, ondersteunde helpers, templates en energie-/statistiekconfiguratie iedere vijf minuten. Ontbrekende statische verwijzingen verschijnen in Reparaties. Configuratie wordt niet gewijzigd; alleen eigen reparatiemeldingen worden beheerd." + } + }, + "abort": { + "already_configured": "Hass-Cleaner Companion is al ingesteld." + } + }, + "issues": { + "missing_references": { + "title": "Ontbrekende verwijzingen in {source}", + "description": "Hass-Cleaner vond {count} ontbrekende statische verwijzingen in {source}.\n\n{references}\n\nOpen de genoemde bron in Home Assistant of het betreffende YAML-configuratieonderdeel en controleer deze paden (lijstindexen beginnen bij 0). Corrigeer het doel of herstel de ontbrekende entiteit, het apparaat, de ruimte, actie of statistiek. Verwijder geen historische statistieken om deze melding te verhelpen. Herlaad YAML-wijzigingen indien nodig. De companion controleert binnen vijf minuten opnieuw.\n\nEr is niets automatisch gewijzigd. Dynamische templates, blueprints en aangepaste kaarten kunnen extra verwijzingen verbergen. Een bron met beperkte dekking kan zijn eerdere melding behouden. Je kunt bewuste verwijzingen in Reparaties negeren. Meer dan 30 bevindingen? De volledige lijst staat in de app en exports." + } + } +} diff --git a/docs/reference-checks.md b/docs/reference-checks.md new file mode 100644 index 0000000..3989c95 --- /dev/null +++ b/docs/reference-checks.md @@ -0,0 +1,110 @@ +# Reference checks and Home Assistant Repairs + +Development preview for Hass-Cleaner 1.1.0. Keep a complete backup and test on a non-critical installation first. This feature does not provide Spook feature parity or prove that anything is safe to delete. + +## Two components + +- **Hass-Cleaner app:** storage and registry review, cleanup preparation and execution. Displays a reference snapshot collected during each new scan. +- **Hass-Cleaner Companion integration:** runs inside Home Assistant Core, reads loaded configuration and maintains native Repairs. Checks after startup and every five minutes, including while the app is stopped. An app scan requests a fresh check. + +The companion never changes automations, scripts, dashboards, entities or devices. Its only Home Assistant writes are its own Repairs issue metadata. It never executes templates or calls the actions it finds. + +## Installation + +1. Use the companion from the **same version** as the app. For this development preview, use the development branch rather than a previous release archive. +2. Copy the repository's `custom_components/hass_cleaner` directory into your Home Assistant configuration directory, resulting in `/config/custom_components/hass_cleaner/manifest.json`. Do not copy it into the app container or nest an extra `hass_cleaner` directory. +3. Restart **Home Assistant Core**, not just the app. +4. Open **Settings → Devices & services → Add integration** and search for **Hass-Cleaner Companion**. Confirm the setup screen. There are no tokens to enter. +5. Start/update Hass-Cleaner to the matching app version and run **New scan**. Open **Entities → Reference checks**. + +The companion runtime adapter targets the Home Assistant 2026.9 APIs. Older versions have not been verified. It is not yet published in HACS. Installing/updating the app alone does not install/update the companion. + +For a local installable ZIP, run `python tools/package_companion.py` from the repository root. Extract the ZIP into the Home Assistant configuration directory; it already contains the `custom_components/hass_cleaner` path. Review and back up any existing companion installation before replacing its files. + +## What gets checked + +### Part 1 implementation scope + +All seven source families are now represented: automations, scripts, dashboards, scenes, groups, helpers/templates and energy/statistics configuration. This is static dependency checking, not complete interpretation of every custom integration or computed template. Live Home Assistant acceptance testing remains a release requirement. + +- **Scenes:** runtime membership, including dynamically created scenes. An integration-provided scene without exposed members is explicitly unavailable. +- **Groups:** runtime legacy groups, configured group helpers and YAML group definitions (including list shorthand). +- **Helpers/templates:** config-entry data/options and relevant YAML sections/platforms, with includes/packages resolved by Home Assistant. Options-shadowed data values are excluded. Supported domains are listed in `custom_components/hass_cleaner/sources.py`: group, template, min/max, integral, utility meter, statistics, derivative, threshold, filter, history statistics, generic thermostat/hygrostat, switch-as-X, time-of-day, input helpers, counter, timer, schedule, random, trend and Bayesian helpers. Custom helper domains are not automatically interpreted. +- **Energy/statistics:** energy preferences, price entities, statistics dashboard cards and explicit Recorder entity filters. Statistics are checked against Recorder metadata (including external statistics), not against live entity states. Historical statistics can remain valid after the entity disappears. If Recorder cannot be read, verification is unknown and no missing-statistic Repair is created. +- YAML helper/template sources represent the **current saved configuration**, which can differ from loaded configuration until reloaded. UI helper sources identify their configuration-entry IDs; YAML paths identify configuration positions rather than source-file line numbers. +- Entity details show counts of matching source configurations by category. Repeated references in one source do not inflate that source count. The same entity can have both live-entity references and retained-statistics references. + +| Source or target | Coverage | +|---|---| +| Loaded automations and scripts | Static entity, device, area and service/action targets, including nested actions and conditions | +| Device automations | Resolve known entity-registry UUIDs to entity IDs; flag missing UUIDs | +| Storage and YAML dashboards | Configuration loaded through Home Assistant, including standard entity lists and target fields | +| Templates | Parse literal calls such as `states('sensor.temperature')` and `states.sensor.temperature`, including standalone template definitions; never execute them | +| Existing entities | Registry entities plus runtime states; disabled entities are not considered missing merely because they have no state | +| Missing actions | Compared with currently registered Home Assistant services/actions after startup | + +Each result includes a source name and ID, target type and ID, and a path such as `$.actions[0].target.entity_id`. Array indexes start at **0**. Paths identify positions in loaded configuration, not YAML file line numbers. The app supports pagination, all/missing filtering, per-source coverage, entity and bundle dependencies, and JSON/CSV/Markdown exports. + +## Coverage and interpretation + +- `completed` means the supported static check completed, **not** that the entire installation was exhaustively checked. +- Dynamic/computed templates, blueprint expansion, custom cards, dashboard strategies and unreadable sources limit coverage. Literal references can still be reported, but the source is marked partial. +- Not covered: arbitrary Python/custom-integration logic, unlisted helper domains, every YAML integration, indirect script/template semantics and every custom-card property. Label/floor targets and Recorder wildcard/domain filters mark limited coverage. This is not a general Home Assistant interpreter. +- Automations/scripts and runtime memberships use loaded configuration; YAML helpers/templates use the saved configuration. Reload changes before checking the running behaviour. Some sources that never loaded may not be discoverable; inspect Home Assistant's own configuration errors too. +- A registered but unavailable entity is **not missing**. An action that temporarily disappears during an integration reload can produce a temporary issue; recheck after the integration has recovered. +- Device and area targets can affect selected entities indirectly. They are shown as potential use, not proof that every selected entity is actually affected. +- Zero findings never proves that removal is safe. The backup choice and explicit user risk acceptance remain required by the normal cleanup workflow. + +## Repairs workflow + +Open **Settings → System → Repairs**. The companion groups missing targets into one stable issue per source, with up to 30 example paths. The app and exports contain the full findings. + +1. Open the named automation, script or dashboard. +2. Correct the reference, restore the missing target, or remove the reference if it is no longer needed. +3. Reload YAML changes where needed. Wait up to five minutes, or request a new app scan. +4. The issue disappears when that source can be fully checked and no missing references remain. Verified removal of a source also resolves its issue after a complete check. + +There is deliberately no automatic **Fix** button. Ignore intentional missing references using the normal Repairs controls. Repeated checks retain the same issue identity and do not reset ignored status. Home Assistant controls dismissal behaviour across its own upgrades; removing/reloading the companion removes its issues and can reset dismissal history. + +An unreadable or partial source does not falsely resolve its previous issue. A previously reported issue may therefore remain until the source is fully readable or its remaining missing references are independently addressed and the source can be fully checked. This is shown as limited coverage in the app. + +## Before registry cleanup + +The preparation contains the selected objects' direct references and potential device/area dependencies, including the scan time. Review these before confirming. When the companion was available during preparation, the app rechecks references and current registry associations before the first removal command. A changed dependency or changed source coverage requires a new scan and review. An unavailable recheck stops execution without changing the registry. + +If the companion was not available at preparation time, the app shows that limitation explicitly and retains the existing user-directed cleanup choice. The check is not an atomic Home Assistant transaction: avoid editing/reloading configuration while executing cleanup. No individual undo is available for registry removal; recovery may require a full backup. + +## Privacy and troubleshooting + +Only source names/IDs, target IDs, configuration paths, coverage and check time leave the companion. Full configurations, service payloads and template bodies are not exported. IDs and names can still reveal personal information: review reports before sharing them. + +The WebSocket command `hass_cleaner/references` is administrator-only. The app uses its normally configured Supervisor authentication. No browser tokens or new secrets are required. If authentication is refused or the companion is missing, the normal registry/file scan continues and reference checks show **unavailable**, never a false all-clear. + +If unavailable: verify installation path, restart Core, add the companion integration, check Core logs, and run a new app scan. Automatic/generated dashboards without a readable configuration may show limited coverage. No repairs from other integrations (including Spook) are deleted or modified. Running both can result in overlapping warnings. + +## Validation before release + +Local automated tests cover the analyzer, WebSocket bridge, HA adapter contracts, Repairs lifecycle, pagination, exports and cleanup revalidation. Contract tests mock Home Assistant interfaces; they do not demonstrate a successful live installation. + +On a disposable Home Assistant 2026.9 instance: + +1. Install/configure the companion and verify a clean startup with no repeated errors. +2. Add a test automation, script and manual dashboard referring to a deliberately nonexistent entity. Verify exact paths, three source issues, app results and English/Dutch text. +3. Correct a reference and reload it. Verify its issue resolves. Ignore another issue and verify repeated checks preserve the choice. +4. Check YAML dashboards, a disabled registered entity, a device trigger using a registry UUID, and a dynamic template. Confirm disabled entities are not missing and dynamic coverage is partial. +5. Temporarily make a test dashboard unreadable. Confirm its prior issue remains and the app shows limited coverage. +6. Prepare registry cleanup only for disposable test entities, change a dependency, and verify execution stops before any removal. +7. Restart Core, reload/remove the companion, and verify scheduled work and issue ownership behave correctly. +8. Stop/remove the companion and confirm the app still scans with an explicit unavailable reference status. +9. Create disposable scene/group/helper/template references. Check both UI-created helpers and YAML packages; verify a legacy group is listed once and changing a helper option removes the old dependency. +10. Verify an external statistic and a retained statistic whose entity no longer exists. They must not produce missing-entity Repairs. An actually missing statistic should produce a finding; unavailable Recorder access should instead show unknown verification. + +## Nederlands kort + +De zeven categorieën uit deel 1 zijn aangesloten, inclusief scènes, groepen, ondersteunde helpers, losse templates en energie/statistieken. Geldige historische/externe statistieken worden niet als verdwenen entiteit gemeld. Zonder Recorder is de statistiekcontrole expliciet onbekend. Niet alle aangepaste helpers of dynamische templates zijn interpreteerbaar; praktijktests blijven nodig voordat dit releaseklaar is. + +- Installeer naast de app de **Hass-Cleaner Companion** in `/config/custom_components/hass_cleaner`, herstart Home Assistant Core en voeg de integratie toe via **Instellingen → Apparaten & diensten**. +- Start een nieuwe appscan. Onder **Entiteiten → Referentiecontrole** staan bronnen, doelen, exacte configuratiepaden en beperkte dekking. +- Meldingen staan bij **Instellingen → Systeem → Reparaties**. Corrigeer zelf de configuratie; de companion controleert iedere vijf minuten. Er wordt niets automatisch hersteld of verwijderd. +- App en companion zijn afzonderlijke onderdelen en moeten afzonderlijk worden bijgewerkt. Versie 1.1.0 is nog een ontwikkelversie; praktijktests zijn nodig. +- Geen verwijzingen gevonden betekent **niet** veilig verwijderen. Templates, blueprints, aangepaste kaarten en onleesbare bronnen kunnen gebruik verbergen. Maak een volledige back-up en controleer de gevolgen. diff --git a/hass_cleaner/CHANGELOG.md b/hass_cleaner/CHANGELOG.md index 9ce2b7b..b0ff738 100644 --- a/hass_cleaner/CHANGELOG.md +++ b/hass_cleaner/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.1.0 — Unreleased + +- Extend reference checks to scene/group membership, supported helper config entries, YAML helper/template definitions (including packages), energy preferences, statistics dashboard cards and explicit Recorder entity filters. +- Verify statistic IDs independently of entity existence; retain valid historical/external statistics and report unknown verification when Recorder cannot be read. +- Display distinct matching source counts and unverified references; include statistic verification changes in pre-cleanup revalidation. + +- Add the optional Hass-Cleaner Companion integration for static reference checks in loaded automations, scripts and YAML/storage dashboards. +- Create native Home Assistant Repairs for missing entity, entity-registry UUID, device, area and action targets. Recheck every five minutes and retain unresolved issues during incomplete checks. +- Show source names, exact configuration paths, coverage limits and potential direct/device/area dependencies in the app and cleanup preparation. +- Include references in JSON, CSV and English/Dutch Markdown exports (report schema 12). +- Revalidate available reference evidence before registry cleanup; require a new review when dependencies or coverage change. +- Provide English and Dutch companion setup/Repairs text and installation guidance. The companion must be installed separately; the app does not modify Home Assistant configuration to install it. +- Version 1.1.0 requires live Home Assistant acceptance testing before publication. + ## 1.0.2 — 2026-09-06 - Apply changed Supervisor configuration fields without discarding unrelated UI choices; migrate the obsolete deletion_mode option at startup. diff --git a/hass_cleaner/DOCS.md b/hass_cleaner/DOCS.md index 70a450e..7ce35d1 100644 --- a/hass_cleaner/DOCS.md +++ b/hass_cleaner/DOCS.md @@ -4,6 +4,8 @@ Storage auditing and user-directed cleanup for Home Assistant OS. Handle with ca ## Getting started +For optional automation/script/dashboard reference checks and native Repairs, install [Hass-Cleaner Companion](https://github.com/dkwolf1/Hass-Cleaner/blob/main/docs/reference-checks.md) separately. App 1.1.0 displays its scan snapshots; without it, the app explicitly reports that reference checks are unavailable. Existing cleanup features continue to work. + 1. Start the app, enable **Show in sidebar**, and open its interface. 2. Run **New scan**. Scanning does not change your Home Assistant files or registries. 3. Review **Scan results**, **Entities** and **Bundles**. Nothing is selected automatically. diff --git a/hass_cleaner/config.yaml b/hass_cleaner/config.yaml index 8793e36..56cc4c4 100644 --- a/hass_cleaner/config.yaml +++ b/hass_cleaner/config.yaml @@ -1,5 +1,5 @@ name: Hass-Cleaner -version: "1.0.2" +version: "1.1.0" slug: hass_cleaner description: Storage audit and controlled cleanup for Home Assistant OS url: https://github.com/dkwolf1/Hass-Cleaner diff --git a/hass_cleaner/hass_cleaner/__init__.py b/hass_cleaner/hass_cleaner/__init__.py index 651b326..2add975 100644 --- a/hass_cleaner/hass_cleaner/__init__.py +++ b/hass_cleaner/hass_cleaner/__init__.py @@ -1,3 +1,3 @@ """Hass-Cleaner Home Assistant App.""" -__version__ = "1.0.2" +__version__ = "1.1.0" diff --git a/hass_cleaner/hass_cleaner/export_text.py b/hass_cleaner/hass_cleaner/export_text.py index 0d28ded..8a2f720 100644 --- a/hass_cleaner/hass_cleaner/export_text.py +++ b/hass_cleaner/hass_cleaner/export_text.py @@ -92,6 +92,8 @@ def report_markdown(report): "- Restore files from quarantine. Existing target files are never overwritten.", "- Registry changes may break dashboards and automations; integrations may recreate objects. Recovery requires a Home Assistant backup.", "- Permanent file deletion requires an expired quarantine period and a separate confirmation.", ""] + from .references import markdown_lines + lines += markdown_lines(registry.get("references", {})) return "\n".join(lines) @@ -118,4 +120,6 @@ def plan_markdown(plan): "- Use a complete Home Assistant backup for registry recovery. Creating and checking one is strongly recommended.", "- Personal content may be lost or unavailable after moving it. Quarantine does not prove that content is unused.", "- Protected system files remain excluded. Cancel if the consequences are unclear.", ""] + from .references import markdown_lines + lines += markdown_lines(plan.get("reference_review", {})) return "\n".join(lines) diff --git a/hass_cleaner/hass_cleaner/plans.py b/hass_cleaner/hass_cleaner/plans.py index fa24ce9..32697bb 100644 --- a/hass_cleaner/hass_cleaner/plans.py +++ b/hass_cleaner/hass_cleaner/plans.py @@ -126,6 +126,9 @@ def create( }) plan_id = uuid.uuid4().hex + from .references import selection_review + reference_review = selection_review(scan.registry_audit, planned_entity_ids, + (item["device_id"] for item in devices)) plan: dict[str, Any] = { "schema_version": 3, "id": plan_id, @@ -139,6 +142,7 @@ def create( "bundles": bundles, "devices": devices, "entities": entities, + "reference_review": reference_review, "summary": { "file_count": len(files), "bundle_count": len(bundles), @@ -241,6 +245,8 @@ def _markdown(plan: dict[str, Any], language: str | None = None) -> str: ) lines.extend(["", "## Algemeen herstel", ""]) lines.extend(f"- {step}" for step in plan["global_recovery"]) + from .references import markdown_lines + lines += markdown_lines(plan.get("reference_review", {}), "nl") lines.append("") return "\n".join(lines) diff --git a/hass_cleaner/hass_cleaner/references.py b/hass_cleaner/hass_cleaner/references.py new file mode 100644 index 0000000..2a848a2 --- /dev/null +++ b/hass_cleaner/hass_cleaner/references.py @@ -0,0 +1,124 @@ +"""Optional, read-only bridge to the Home Assistant companion integration.""" +from __future__ import annotations + +import json +import os + + +def unavailable(reason="companion_unavailable"): + return {"schema_version": 1, "status": "unavailable", "reason": reason, + "sources": [], "references": [], "summary": {}} + + +def fetch_references(token, *, connect=None): + from .registry_audit import WEBSOCKET_URL, _receive_json, _receive_result + connection = None + try: + if connect is None: + import websocket + connect = websocket.create_connection + connection = connect(WEBSOCKET_URL, timeout=30, header=[f"Authorization: Bearer {token}"]) + authentication = _receive_json(connection) + if authentication.get("type") == "auth_required": + connection.send(json.dumps({"type": "auth", "access_token": token})) + authentication = _receive_json(connection) + if authentication.get("type") != "auth_ok": + return unavailable("authentication_failed") + connection.send(json.dumps({"id": 1, "type": "hass_cleaner/references", "refresh": True})) + report = _receive_result(connection, 1).get("result") + if not isinstance(report, dict) or report.get("schema_version") != 1: + return unavailable("unsupported_report") + if report.get("status") not in {"completed", "partial", "starting", "unavailable"}: + return unavailable("invalid_report") + if not isinstance(report.get("sources"), list) or not isinstance(report.get("references"), list): + return unavailable("invalid_report") + if not isinstance(report.get("summary"), dict) or any(type(v) is not int or v < 0 for v in report["summary"].values()): + return unavailable("invalid_report") + for source in report["sources"]: + if (not isinstance(source, dict) or source.get("status") not in {"checked", "partial", "unavailable"} + or not all(isinstance(source.get(k), str) for k in ("id", "kind", "name"))): + return unavailable("invalid_report") + for item in report["references"]: + if (not isinstance(item, dict) or type(item.get("missing")) is not bool + or item.get("target_type") not in {"entity", "entity_registry", "device", "area", "action", "statistic"} + or not all(isinstance(item.get(key), str) for key in + ("source_id", "source_kind", "source_name", "target_id", "location"))): + return unavailable("invalid_report") + return {key: value for key, value in report.items() if key in { + "schema_version", "status", "checked_at", "sources", "references", "summary", "limitations"}} + except Exception: + # Missing companion, permission refusal and connectivity failures never + # masquerade as a successful check or break the normal registry scan. + return unavailable() + finally: + if connection is not None: + try: + connection.close() + except Exception: + pass + + +def selection_review(audit, entity_ids=(), device_ids=()): + """Include direct references plus potential device/area-targeted effects.""" + report = audit.references + entities, devices = set(entity_ids), set(device_ids) + areas = set() + for bundle in audit.bundles: + for entity in bundle.entities: + if entity.get("entity_id") in entities: + devices.add(entity.get("device_id", "")) + areas.add(entity.get("area_id", "")) + for device in bundle.devices: + if device.get("device_id") in devices: + areas.add(device.get("area_id", "")) + targets = {"entity": entities, "statistic": entities, "device": devices - {""}, "area": areas - {""}} + matches = [r for r in report.get("references", []) if r["target_id"] in targets.get(r["target_type"], set())] + return {"status": report.get("status", "unavailable"), "checked_at": report.get("checked_at"), + "summary": report.get("summary", {}), "references": matches, + "count": len(matches), "limitations": report.get("limitations", [])} + + +def markdown_lines(report, language="en"): + from .export_text import table, cell + nl = language == "nl" + lines = ["", "## Referentiecontrole" if nl else "## Reference checks", "", + f"Status: {cell(report.get('status', 'unavailable'))}", + f"{'Momentopname' if nl else 'Snapshot'}: {cell(report.get('checked_at') or '—')}", "", + "Geen verwijzingen gevonden betekent niet dat verwijderen veilig is. Dynamische templates, blueprints, aangepaste kaarten en onleesbare bronnen beperken de dekking. Apparaat- en ruimtedoelen tonen mogelijk indirect gebruik." + if nl else "No references found does not mean removal is safe. Dynamic templates, blueprints, custom cards and unreadable sources limit coverage. Device and area targets indicate potential indirect use."] + lines += table(["Bron", "Bron-ID", "Doeltype", "Doel", "Locatie", "Ontbreekt"] if nl else + ["Source", "Source ID", "Target type", "Target", "Location", "Missing"], + [(r["source_name"], r["source_id"], r["target_type"], r["target_id"], r["location"], + ("onbekend" if nl else "unknown") if r.get("verification") == "unavailable" else + ("ja" if nl else "yes") if r["missing"] else ("nee" if nl else "no")) for r in report.get("references", [])]) + if report.get("sources"): + lines += table(["Bron", "Dekking"] if nl else ["Source", "Coverage"], + [(s["name"], s["status"]) for s in report["sources"]]) + return lines + + +def validate_before_cleanup(scan, plan): + """Do not execute against dependencies that differ from the reviewed scan.""" + from .registry_cleanup import RegistryCleanupError + from .registry_audit import audit_registry_snapshot, fetch_registry_snapshot + before = plan.get("reference_review", {}) + if before.get("status") not in {"completed", "partial"}: + return # Optional companion was explicitly unavailable in the preview. + token = os.environ.get("SUPERVISOR_TOKEN") + report = fetch_references(token) if token else unavailable() + if report.get("status") not in {"completed", "partial"}: + raise RegistryCleanupError("Reference recheck unavailable. No registry changes were made; scan again before proceeding.") + try: + audit = audit_registry_snapshot(fetch_registry_snapshot(token)) + except Exception as exc: + raise RegistryCleanupError("Reference targets could not be revalidated. Scan again before proceeding.") from exc + audit.references = report + after = selection_review(audit, [e["entity_id"] for e in plan.get("entities", [])], + [d["device_id"] for d in plan.get("devices", [])]) + def signature(value): + return sorted((r["source_id"], r["target_type"], r["target_id"], r["location"], r["missing"], r.get("verification", "checked")) + for r in value.get("references", [])) + coverage_before = sorted((s["id"], s["status"]) for s in scan.registry_audit.references.get("sources", [])) + coverage_after = sorted((s["id"], s["status"]) for s in report.get("sources", [])) + if signature(before) != signature(after) or coverage_before != coverage_after: + raise RegistryCleanupError("References or check coverage have changed. No registry changes were made; scan again and review the new cleanup preparation.") diff --git a/hass_cleaner/hass_cleaner/registry_audit.py b/hass_cleaner/hass_cleaner/registry_audit.py index 6c79744..02669af 100644 --- a/hass_cleaner/hass_cleaner/registry_audit.py +++ b/hass_cleaner/hass_cleaner/registry_audit.py @@ -53,6 +53,7 @@ class RegistryAudit: entity_workspace: dict[str, Any] = field(default_factory=dict) state_only_entities: list[dict[str, Any]] = field(default_factory=list) error: str | None = None + references: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, object]: return { @@ -64,6 +65,7 @@ def to_dict(self) -> dict[str, object]: "entity_workspace": self.entity_workspace, "state_only_entities": self.state_only_entities, "error": self.error, + "references": self.references, "audit_only": True, "destructive_actions_available": False, } @@ -78,7 +80,10 @@ def scan_home_assistant_registries() -> RegistryAudit: ) try: snapshot = fetch_registry_snapshot(token) - return audit_registry_snapshot(snapshot) + audit = audit_registry_snapshot(snapshot) + from .references import fetch_references + audit.references = fetch_references(token) + return audit except Exception as exc: # fail closed: a file audit may still complete return RegistryAudit( status="failed", diff --git a/hass_cleaner/hass_cleaner/registry_cleanup.py b/hass_cleaner/hass_cleaner/registry_cleanup.py index 2b22588..88a67cf 100644 --- a/hass_cleaner/hass_cleaner/registry_cleanup.py +++ b/hass_cleaner/hass_cleaner/registry_cleanup.py @@ -172,6 +172,8 @@ def execute(self, scan, plan: dict[str, Any], *, backup_choice: str, backup_toke "completed": [], } try: + from .references import validate_before_cleanup + validate_before_cleanup(scan, plan) history = self.history() history.insert(0, record) # An unavailable journal must prevent the first external command. diff --git a/hass_cleaner/hass_cleaner/reporting.py b/hass_cleaner/hass_cleaner/reporting.py index 2020e37..f2ae99d 100644 --- a/hass_cleaner/hass_cleaner/reporting.py +++ b/hass_cleaner/hass_cleaner/reporting.py @@ -9,7 +9,7 @@ from .settings import Settings -REPORT_SCHEMA_VERSION = 11 +REPORT_SCHEMA_VERSION = 12 REPORT_EXTENSIONS = {"json", "csv", "md"} @@ -241,6 +241,15 @@ def _write_csv(scan: ScanResult, path: Path) -> None: }, ensure_ascii=False), ] ) + for ref in scan.registry_audit.references.get("references", []): + unverified = ref.get("verification") == "unavailable" + row = ["reference", ref["source_id"], ref["location"], ref["target_type"], ref["target_id"], + ref["source_name"], "unverified_reference" if unverified else "missing_reference" if ref["missing"] else "existing_reference", + "review" if ref["missing"] or unverified else "info", "no", "review_source", 0, + scan.registry_audit.references.get("checked_at", ""), "Static reference; coverage is limited", + "static", "References may break after removal", "Review the source configuration", ""] + # Names and paths are user-controlled; prevent spreadsheet formula execution. + writer.writerow(["'" + value if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r")) else value for value in row]) def _markdown(report: dict[str, object], language: str | None = None) -> str: @@ -381,6 +390,8 @@ def _markdown(report: dict[str, object], language: str | None = None) -> str: "", ] ) + from .references import markdown_lines + lines += markdown_lines(report["scan"].get("registry_audit", {}).get("references", {}), "nl") return "\n".join(lines) diff --git a/hass_cleaner/hass_cleaner/server.py b/hass_cleaner/hass_cleaner/server.py index 312e442..b3c618f 100644 --- a/hass_cleaner/hass_cleaner/server.py +++ b/hass_cleaner/hass_cleaner/server.py @@ -137,7 +137,7 @@ def do_GET(self) -> None: scan_id = path.split("/")[3] scan = self.state.scan_manager.get(scan_id) self._json(_scan_summary(scan) if scan else {"error": "Scan niet gevonden"}, HTTPStatus.OK if scan else HTTPStatus.NOT_FOUND) - elif re.fullmatch(r"/api/scans/[a-zA-Z0-9]+/(entities|files|bundles)", path): + elif re.fullmatch(r"/api/scans/[a-zA-Z0-9]+/(entities|files|bundles|references)", path): parts = path.split("/") scan = self.state.scan_manager.get(parts[3]) if scan is None: @@ -569,6 +569,7 @@ def _scan_summary(scan) -> dict[str, object]: [asdict(item) for item in islice((item for item in source.findings if item.severity != "review"), 250)]} workspace = audit.get("entity_workspace", {}) payload["registry_audit"] = { + "references": {key: value for key, value in source.references.items() if key not in {"references", "sources"}}, "status": audit.get("status"), "error": audit.get("error"), "summary": audit.get("summary", {}), @@ -595,6 +596,27 @@ def _paged_scan_items(scan, kind: str, query: dict[str, list[str]]) -> dict[str, offset, limit = 0, 100 search = query.get("q", [""])[0].strip().lower() status = query.get("status", ["all"])[0] + if kind == "references": + report = scan.registry_audit.references + items = report.get("references", []) + entity_id = query.get("entity_id", [""])[0] + bundle_id = query.get("bundle_id", [""])[0] + if entity_id or bundle_id: + from .references import selection_review + bundle = next((b for b in scan.registry_audit.bundles if b.id == bundle_id), None) + entity_ids = [entity_id] if entity_id else [e.get("entity_id", "") for e in bundle.entities] if bundle else [] + device_ids = [d.get("device_id", "") for d in bundle.devices] if bundle else [] + items = selection_review(scan.registry_audit, entity_ids, device_ids)["references"] + if status == "missing": + items = [r for r in items if r["missing"]] + if search: + items = [r for r in items if search in " ".join(r[k] for k in ("source_name", "target_id", "location")).lower()] + source_counts = {kind: len({r["source_id"] for r in items if r["source_kind"] == kind}) + for kind in {r["source_kind"] for r in items}} + return {"items": items[offset:offset + limit], "total": len(items), "offset": offset, "source_counts": source_counts, + "limit": limit, "has_more": offset + limit < len(items), + "status": report.get("status", "unavailable"), "checked_at": report.get("checked_at"), + "summary": report.get("summary", {}), "sources": report.get("sources", [])} if kind == "entities": all_items = list(scan.registry_audit.entity_workspace.get("items", [])) items = all_items diff --git a/hass_cleaner/tests/test_companion.py b/hass_cleaner/tests/test_companion.py new file mode 100644 index 0000000..3cc1fbf --- /dev/null +++ b/hass_cleaner/tests/test_companion.py @@ -0,0 +1,155 @@ +"""HA adapter contract tests. These do not replace testing inside a live HA Core.""" +from __future__ import annotations + +import asyncio +import importlib.util +import json +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +from tests.test_references import COMPANION, analyzer, source + + +def load_companion(): + identity = lambda func: func + ws = SimpleNamespace(websocket_command=lambda schema: identity, async_response=identity, + async_register_command=Mock()) + modules = { + "homeassistant": SimpleNamespace(), + "homeassistant.components": SimpleNamespace(websocket_api=ws), + "homeassistant.const": SimpleNamespace(EVENT_HOMEASSISTANT_STARTED="started"), + "homeassistant.core": SimpleNamespace(callback=identity), + "homeassistant.helpers": SimpleNamespace(area_registry=SimpleNamespace(), device_registry=SimpleNamespace(), + entity_registry=SimpleNamespace(), issue_registry=SimpleNamespace()), + "homeassistant.helpers.event": SimpleNamespace(async_track_time_interval=Mock()), + } + spec = importlib.util.spec_from_file_location("_companion_contract", COMPANION / "__init__.py", + submodule_search_locations=[str(COMPANION)]) + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, {**modules, "_companion_contract": module}): + spec.loader.exec_module(module) + module.collect_extended_sources = AsyncMock(return_value=[]) + return module + + +class CompanionTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.companion = load_companion() + self.issues = {} + def create(hass, domain, issue_id, **kwargs): + previous = self.issues.get((domain, issue_id)) + self.issues[domain, issue_id] = SimpleNamespace(**kwargs, ignored=getattr(previous, "ignored", False)) + def delete(hass, domain, issue_id): + self.issues.pop((domain, issue_id), None) + self.companion.ir = SimpleNamespace(async_get=lambda hass: SimpleNamespace(issues=self.issues), + async_create_issue=create, async_delete_issue=delete, + IssueSeverity=SimpleNamespace(ERROR="error")) + self.hass = SimpleNamespace(data={}, config=SimpleNamespace(components=set()), is_running=True, + async_add_executor_job=AsyncMock(side_effect=lambda func, *args: func(*args))) + self.monitor = self.companion.ReferenceMonitor(self.hass) + + async def test_repairs_stable_ignored_and_auto_resolved(self): + broken = analyzer.analyze([source({"entity_id": "sensor.old"})], {}) + self.monitor.reconcile(broken) + key = next(iter(self.issues)) + self.assertFalse(self.issues[key].is_fixable) + self.assertFalse(self.issues[key].is_persistent) + self.issues[key].ignored = True + self.monitor.reconcile(broken) + self.assertTrue(self.issues[key].ignored) + self.monitor.reconcile(analyzer.analyze([source({"entity_id": "sensor.old"})], {"entity": {"sensor.old"}})) + self.assertEqual({}, self.issues) + + async def test_failed_or_partial_source_does_not_resolve_issue(self): + self.monitor.reconcile(analyzer.analyze([source({"entity_id": "sensor.old"})], {})) + self.monitor.reconcile(analyzer.analyze([source(None)], {})) + self.assertEqual(1, len(self.issues)) + self.monitor.reconcile(analyzer.analyze([source({"value_template": "{{ dynamic }}"})], {})) + self.assertEqual(1, len(self.issues)) + self.monitor.reconcile(analyzer.analyze([], {})) + self.assertEqual({}, self.issues) + + async def test_other_integrations_and_other_issues_untouched(self): + self.issues["spook", "reference_test"] = SimpleNamespace(data={}) + self.issues["hass_cleaner", "other"] = SimpleNamespace(data={}) + self.monitor.reconcile(analyzer.analyze([], {})) + self.assertEqual(2, len(self.issues)) + + async def test_collect_loaded_configs_and_isolate_dashboard_failure(self): + self.hass.data = { + "automation": SimpleNamespace(entities=[SimpleNamespace(entity_id="automation.a", name="A", raw_config={"entity_id": "light.a"})]), + "script": SimpleNamespace(entities=[SimpleNamespace(entity_id="script.a", name=None, raw_config=None)]), + "lovelace": SimpleNamespace(dashboards={ + None: SimpleNamespace(config=None, async_load=AsyncMock(return_value={"views": []})), + "lovelace": SimpleNamespace(config={"title": "Overview YAML"}, async_load=AsyncMock(return_value={"views": []})), + "broken": SimpleNamespace(config={"title": "Broken"}, async_load=AsyncMock(side_effect=ValueError("SECRET")))})} + sources = await self.companion.collect_sources(self.hass) + self.assertEqual(5, len(sources)) + self.assertEqual(5, len({s["id"] for s in sources})) + self.assertNotIn("SECRET", json.dumps(sources)) + self.assertIsNone(sources[1]["config"]) + self.assertIn("error", sources[-1]) + + async def test_websocket_admin_only_and_not_loaded(self): + connection = SimpleNamespace(user=SimpleNamespace(is_admin=False), send_error=Mock(), send_result=Mock()) + await self.companion.websocket_references(self.hass, connection, {"id": 1, "refresh": True}) + self.assertEqual("unauthorized", connection.send_error.call_args.args[1]) + connection.user.is_admin = True + await self.companion.websocket_references(self.hass, connection, {"id": 2, "refresh": True}) + self.assertEqual("not_loaded", connection.send_error.call_args.args[1]) + monitor = SimpleNamespace(report={"status": "completed"}, refresh=AsyncMock()) + self.hass.data["hass_cleaner"] = monitor + await self.companion.websocket_references(self.hass, connection, {"id": 3, "refresh": False}) + monitor.refresh.assert_not_awaited() + connection.send_result.assert_called_once_with(3, monitor.report) + await self.companion.websocket_references(self.hass, connection, {"id": 4, "refresh": True}) + monitor.refresh.assert_awaited_once() + + async def test_waits_for_startup_and_failed_refresh_retains_issues(self): + self.hass.is_running = False + await self.monitor.refresh() + self.assertEqual("starting", self.monitor.report["status"]) + self.monitor.reconcile(analyzer.analyze([source({"entity_id": "sensor.old"})], {})) + self.hass.is_running = True + with patch.object(self.companion, "collect_sources", AsyncMock(side_effect=ValueError("SECRET"))): + with self.assertLogs(self.companion._LOGGER, "WARNING") as logs: + await self.monitor.refresh() + self.assertNotIn("SECRET", str(logs.output)) + self.assertEqual("unavailable", self.monitor.report["status"]) + self.assertEqual(1, len(self.issues)) + + async def test_refresh_runs_analyzer_off_event_loop(self): + self.hass.states = SimpleNamespace(async_entity_ids=lambda: ["sensor.runtime"]) + self.hass.services = SimpleNamespace(async_services=lambda: {"light": {"turn_on": {}}}) + self.companion.er = SimpleNamespace(async_get=lambda h: SimpleNamespace(entities={"sensor.disabled": SimpleNamespace(id="a" * 32, entity_id="sensor.disabled")})) + self.companion.dr = SimpleNamespace(async_get=lambda h: SimpleNamespace(devices={})) + self.companion.ar = SimpleNamespace(async_get=lambda h: SimpleNamespace(areas={})) + with patch.object(self.companion, "collect_sources", AsyncMock(return_value=[source({"entity_id": ["sensor.runtime", "sensor.disabled"], "action": "light.turn_on"})])): + await self.monitor.refresh() + self.assertEqual(0, self.monitor.report["summary"]["missing"]) + self.hass.async_add_executor_job.assert_awaited_once() + + async def test_unload_stops_future_work_and_removes_only_own_repairs(self): + self.hass.data["hass_cleaner"] = self.monitor + self.monitor.reconcile(analyzer.analyze([source({"entity_id": "sensor.old"})], {})) + self.issues["spook", "reference_test"] = SimpleNamespace(data={}) + await self.companion.async_unload_entry(self.hass, None) + self.assertTrue(self.monitor.closed) + await self.monitor.refresh() + self.hass.async_add_executor_job.assert_not_awaited() + self.assertEqual([("spook", "reference_test")], list(self.issues)) + + async def test_translation_placeholders_match(self): + from hass_cleaner import __version__ + manifest = json.loads((COMPANION / "manifest.json").read_text()) + self.assertEqual(__version__, manifest["version"]) + self.assertTrue(manifest["config_flow"]) + strings = json.loads((COMPANION / "strings.json").read_text()) + for language in ("en", "nl"): + value = json.loads((COMPANION / "translations" / f"{language}.json").read_text()) + issue = value["issues"]["missing_references"] + for placeholder in ("source", "count", "references"): + self.assertIn("{" + placeholder + "}", issue["description"]) + self.assertEqual(strings, json.loads((COMPANION / "translations" / "en.json").read_text())) diff --git a/hass_cleaner/tests/test_reference_sources.py b/hass_cleaner/tests/test_reference_sources.py new file mode 100644 index 0000000..66d699a --- /dev/null +++ b/hass_cleaner/tests/test_reference_sources.py @@ -0,0 +1,107 @@ +"""Additional source-family and Recorder verification regressions.""" +import importlib.util +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from tests.test_references import COMPANION, analyzer, source + +spec = importlib.util.spec_from_file_location("extended_sources_test", COMPANION / "sources.py") +sources = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sources) + + +class ExtraSourceTests(unittest.IsolatedAsyncioTestCase): + async def test_yaml_group_dedup_and_options_override(self): + hass = SimpleNamespace(states=SimpleNamespace(async_all=lambda: [SimpleNamespace(entity_id="group.lights", name="Lights", attributes={"entity_id": ["light.a"]})]), + config_entries=SimpleNamespace(async_entries=lambda: [SimpleNamespace(domain="derivative", entry_id="h1", title="Slope", data={"source": "sensor.old"}, options={"source": "sensor.new"})]), + config=SimpleNamespace(components=set())) + with patch.dict(sys.modules, {"homeassistant.config": SimpleNamespace(async_hass_config_yaml=AsyncMock(return_value={"group": {"lights": ["light.a"]}}))}): + result = await sources.collect_extended_sources(hass) + self.assertEqual(1, len([s for s in result if s["id"] == "group.lights"])) + ids = {r["target_id"] for r in analyzer.analyze(result, {})["references"]} + self.assertIn("sensor.new", ids) + self.assertNotIn("sensor.old", ids) + + def test_yaml_groups_helpers_templates_and_recorder(self): + config = {"group": {"lights": ["light.one", "light.two"]}, + "sensor": [{"platform": "derivative", "source": "sensor.power"}, + {"platform": "template", "sensors": {"calculated": {"value_template": "{{ states('sensor.input') }}"}}}], + "utility_meter": {"monthly": {"source": "sensor.energy"}}, + "recorder": {"db_url": "SECRET", "include": {"entities": ["sensor.temperature"]}}, + "http": {"password": "SECRET"}} + found = sources.yaml_sources(config) + self.assertEqual(5, len(found)) + self.assertNotIn("SECRET", str(found)) + report = analyzer.analyze(found, {}) + self.assertEqual({"light.one", "light.two", "sensor.power", "sensor.input", "sensor.energy", "sensor.temperature"}, + {r["target_id"] for r in report["references"]}) + + def test_scene_members_not_scene_attributes_are_targets(self): + report = analyzer.analyze([source({"entities": {"light.one": {"state": "on", "effect": "sensor.not_reference"}}}, "scene")], {}) + self.assertEqual(["light.one"], [r["target_id"] for r in report["references"]]) + + def test_helpers_plain_text_is_not_executed_as_template(self): + report = analyzer.analyze([source({"initial": "{{ states('sensor.example') }}"}, "helper")], {}) + self.assertEqual([], report["references"]) + energy = analyzer.analyze([source({"stat_energy_from": "{{ SECRET }}"}, "energy")], {"statistic": set()}) + self.assertNotIn("SECRET", str(energy)) + self.assertEqual("partial", energy["status"]) + + def test_energy_statistics_external_history_and_prices(self): + report = analyzer.analyze([source({"energy_sources": [{"stat_energy_from": "sensor.old_meter", "stat_energy_to": "external:meter", + "entity_energy_price": "sensor.price"}], "device_consumption": [{"stat_consumption": "sensor.missing"}]}, "energy")], + {"statistic": {"sensor.old_meter", "external:meter"}, "entity": {"sensor.price"}}) + self.assertEqual(["sensor.missing"], [r["target_id"] for r in report["references"] if r["missing"]]) + self.assertEqual(1, len(analyzer.repair_groups(report))) + + def test_missing_recorder_never_claims_statistics_are_missing_or_checked(self): + report = analyzer.analyze([source({"stat_energy_from": "external:meter"}, "energy")], {"statistic": None}) + self.assertEqual("partial", report["status"]) + self.assertEqual("unavailable", report["references"][0]["verification"]) + self.assertFalse(report["references"][0]["missing"]) + self.assertEqual({}, analyzer.repair_groups(report)) + + def test_statistics_dashboard_uses_statistics_not_entity_existence(self): + report = analyzer.analyze([source({"type": "statistics-graph", "entities": ["external:meter", {"entity": "sensor.old"}]}, "dashboard")], + {"statistic": {"external:meter", "sensor.old"}}) + self.assertEqual(2, len(report["references"])) + self.assertTrue(all(r["target_type"] == "statistic" and not r["missing"] for r in report["references"])) + + async def test_runtime_and_config_entry_sources_plus_energy(self): + states = [SimpleNamespace(entity_id="scene.night", name="Night", attributes={"entity_id": ["light.one"]}), + SimpleNamespace(entity_id="group.all", name="All", attributes={"entity_id": ["light.one"]}), + SimpleNamespace(entity_id="scene.remote", name="Remote", attributes={})] + entries = [SimpleNamespace(domain="min_max", entry_id="helper1", title="Average", data={}, options={"entity_ids": ["sensor.a"]}), + SimpleNamespace(domain="unrelated", entry_id="private", title="Other", data={"password": "SECRET"}, options={})] + hass = SimpleNamespace(states=SimpleNamespace(async_all=lambda: states), config_entries=SimpleNamespace(async_entries=lambda: entries), + config=SimpleNamespace(components={"energy"})) + modules = {"homeassistant.config": SimpleNamespace(async_hass_config_yaml=AsyncMock(return_value={})), + "homeassistant.components.energy.data": SimpleNamespace(async_get_manager=AsyncMock(return_value=SimpleNamespace(data={"stat_energy_from": "sensor.energy"})))} + with patch.dict(sys.modules, modules): + result = await sources.collect_extended_sources(hass) + self.assertEqual(5, len(result)) + self.assertNotIn("SECRET", str(result)) + report = analyzer.analyze(result, {"statistic": set()}) + self.assertEqual("unavailable", report["sources"][2]["status"]) + self.assertIn("sensor.a", [r["target_id"] for r in report["references"]]) + + async def test_yaml_and_energy_errors_are_isolated_and_redacted(self): + hass = SimpleNamespace(states=SimpleNamespace(async_all=lambda: []), config_entries=SimpleNamespace(async_entries=lambda: []), + config=SimpleNamespace(components={"energy"})) + with patch.dict(sys.modules, {"homeassistant.config": SimpleNamespace(async_hass_config_yaml=AsyncMock(side_effect=ValueError("SECRET"))), + "homeassistant.components.energy.data": SimpleNamespace(async_get_manager=AsyncMock(side_effect=ValueError("SECRET")))}): + result = await sources.collect_extended_sources(hass) + self.assertEqual(2, len(result)) + self.assertNotIn("SECRET", str(result)) + self.assertTrue(all("error" in r for r in result)) + + async def test_statistics_executor_success_and_failure(self): + hass = SimpleNamespace(config=SimpleNamespace(components={"recorder"})) + executor = AsyncMock(return_value=[{"statistic_id": "external:energy"}]) + with patch.dict(sys.modules, {"homeassistant.components.recorder": SimpleNamespace(get_instance=lambda h: SimpleNamespace(async_add_executor_job=executor)), + "homeassistant.components.recorder.statistics": SimpleNamespace(list_statistic_ids=lambda h: None)}): + self.assertEqual({"external:energy"}, await sources.statistic_ids(hass)) + executor.side_effect = RuntimeError("offline") + self.assertIsNone(await sources.statistic_ids(hass)) diff --git a/hass_cleaner/tests/test_references.py b/hass_cleaner/tests/test_references.py new file mode 100644 index 0000000..6824fc1 --- /dev/null +++ b/hass_cleaner/tests/test_references.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from hass_cleaner.references import fetch_references, selection_review, markdown_lines +from hass_cleaner.references import validate_before_cleanup +from hass_cleaner.registry_cleanup import RegistryCleanupError, RegistryCleanupManager +from hass_cleaner.registry_audit import audit_registry_snapshot +from hass_cleaner.server import _paged_scan_items, _scan_summary +from hass_cleaner.scanner import ScanResult +from hass_cleaner.plans import PlanManager, _markdown +from hass_cleaner.settings import Settings +from hass_cleaner.reporting import _write_csv +from tests.test_registry_audit import FakeConnection + +COMPANION = Path(__file__).resolve().parents[2] / "custom_components" / "hass_cleaner" +spec = importlib.util.spec_from_file_location("reference_analyzer", COMPANION / "references.py") +analyzer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(analyzer) + + +def source(config, kind="automation", source_id="automation.test"): + return {"id": source_id, "name": "Test source", "kind": kind, "config": config} + + +class ReferenceTests(unittest.TestCase): + def test_cleanup_rechecks_unchanged_changed_and_unavailable_references(self): + snapshot = {"entities": [{"entity_id": "light.a"}]} + audit = audit_registry_snapshot(snapshot) + audit.references = analyzer.analyze([source({"entity_id": "light.a"})], {"entity": {"light.a"}}) + scan = SimpleNamespace(registry_audit=audit) + plan = {"entities": [{"entity_id": "light.a"}], "reference_review": selection_review(audit, ["light.a"])} + with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "test"}), patch("hass_cleaner.registry_audit.fetch_registry_snapshot", return_value=snapshot), patch("hass_cleaner.references.fetch_references", return_value=audit.references) as fetch: + validate_before_cleanup(scan, plan) + fetch.return_value = analyzer.analyze([source({"target": {"entity_id": "light.a"}})], {"entity": {"light.a"}}) + with self.assertRaisesRegex(RegistryCleanupError, "changed"): + validate_before_cleanup(scan, plan) + fetch.return_value = {"status": "unavailable"} + with self.assertRaisesRegex(RegistryCleanupError, "unavailable"): + validate_before_cleanup(scan, plan) + validate_before_cleanup(scan, {"reference_review": {"status": "unavailable"}}) + + def test_cleanup_coverage_change_blocks_even_without_matches(self): + audit = audit_registry_snapshot({}) + audit.references = analyzer.analyze([source({})], {}) + plan = {"reference_review": selection_review(audit)} + after = analyzer.analyze([source({"use_blueprint": {}})], {}) + with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "test"}), patch("hass_cleaner.registry_audit.fetch_registry_snapshot", return_value={}), patch("hass_cleaner.references.fetch_references", return_value=after): + with self.assertRaisesRegex(RegistryCleanupError, "coverage"): + validate_before_cleanup(SimpleNamespace(registry_audit=audit), plan) + + def test_failed_reference_recheck_prevents_executor_and_releases_lock(self): + from tests.test_registry_cleanup import RegistryCleanupTests + calls = [] + plan = {"scan_id": "scan1", "entities": [{"entity_id": "sensor.old", "execution_allowed": True}]} + with tempfile.TemporaryDirectory() as temp: + manager = RegistryCleanupManager(Path(temp), executor=lambda *a, **k: calls.append(True)) + with patch("hass_cleaner.references.validate_before_cleanup", side_effect=RegistryCleanupError("changed")): + with self.assertRaisesRegex(RegistryCleanupError, "changed"): + manager.execute(RegistryCleanupTests._scan(), plan, backup_choice="manual", backup_token="", + backup_valid=False, risk_acknowledged=True, confirmation="DELETE 1", requested_by="test") + self.assertEqual([], calls) + self.assertEqual([], manager.history()) + self.assertTrue(manager._lock.acquire(blocking=False)) + manager._lock.release() + + def test_nested_static_targets_and_locations(self): + report = analyzer.analyze([source({"actions": [{"action": "light.turn_on", "target": { + "entity_id": ["light.present", "light.missing"], "device_id": "a" * 32, "area_id": "kitchen"}}]})], + {"entity": {"light.present"}, "device": {"a" * 32}, "area": {"kitchen"}, "action": {"light.turn_on"}}) + self.assertEqual("completed", report["status"]) + self.assertEqual(5, report["summary"]["references"]) + self.assertEqual(1, report["summary"]["missing"]) + missing = next(r for r in report["references"] if r["missing"]) + self.assertEqual("$.actions[0].target.entity_id[1]", missing["location"]) + + def test_registry_entities_need_not_have_a_state(self): + report = analyzer.analyze([source({"entity_id": "sensor.disabled"})], {"entity": {"sensor.disabled"}}) + self.assertEqual(0, report["summary"]["missing"]) + + def test_device_automation_entity_uuid(self): + report = analyzer.analyze([source({"entity_id": ["a" * 32, "b" * 32]})], + {"entity": {"light.present"}, "entity_aliases": {"a" * 32: "light.present"}}) + self.assertEqual(["light.present", "b" * 32], [r["target_id"] for r in report["references"]]) + self.assertEqual("entity_registry", report["references"][1]["target_type"]) + self.assertTrue(report["references"][1]["missing"]) + + def test_templates_are_parsed_not_evaluated(self): + config = {"value_template": "{{ states('sensor.literal') }} {{ states.sensor.attribute.state }} {{ states(entity_variable) }}"} + report = analyzer.analyze([source(config)], {}) + self.assertEqual("partial", report["status"]) + self.assertEqual({"sensor.literal", "sensor.attribute"}, {r["target_id"] for r in report["references"]}) + self.assertNotIn("entity_variable", json.dumps(report)) + self.assertEqual(set(), analyzer.template_entities("{{ 'states.sensor.example' }} {# states('sensor.comment') #}")) + self.assertEqual(set(), analyzer.template_entities("{{ invalid syntax !!! }}")) + + def test_dashboard_lists_blueprints_custom_cards_and_unreadable_sources(self): + report = analyzer.analyze([source({"views": [{"cards": [{"type": "custom:test", "entities": ["sensor.a", {"entity": "sensor.b"}]}]}]}, "dashboard"), + source({"use_blueprint": {"path": "example.yaml"}}, source_id="automation.blueprint"), + source(None, source_id="script.failed")], {}) + self.assertEqual(2, report["summary"]["missing"]) + self.assertEqual(3, report["summary"]["partial_sources"]) + self.assertEqual("unavailable", report["sources"][2]["status"]) + + def test_no_credentials_or_entire_configuration_exported(self): + report = analyzer.analyze([source({"password": "SECRET", "message": "sensor.not_a_reference", "entity_id": "all", "action": "choose"})], {}) + self.assertNotIn("SECRET", json.dumps(report)) + self.assertEqual([], report["references"]) + + def test_issue_id_stays_stable_for_changed_targets(self): + first = analyzer.repair_groups(analyzer.analyze([source({"entity_id": "sensor.a"})], {})) + second = analyzer.repair_groups(analyzer.analyze([source({"entity_id": "sensor.b"})], {})) + self.assertEqual(list(first), list(second)) + + def test_depth_limit_marks_partial(self): + config = {} + for _ in range(65): + config = {"nested": config} + self.assertEqual("partial", analyzer.analyze([source(config)], {})["status"]) + + def test_bridge_read_only_and_missing_companion(self): + report = analyzer.analyze([source({"entity_id": "sensor.a"})], {}) + connection = FakeConnection([{"type": "auth_required"}, {"type": "auth_ok"}, + {"type": "result", "id": 1, "success": True, "result": report}]) + self.assertEqual(report, fetch_references("test-token", connect=lambda *a, **k: connection)) + self.assertEqual("hass_cleaner/references", connection.sent[-1]["type"]) + self.assertTrue(connection.closed) + connection = FakeConnection([{"type": "auth_ok"}, {"type": "result", "id": 1, "success": False, + "error": {"code": "unknown_command", "message": "unknown"}}]) + self.assertEqual("unavailable", fetch_references("test", connect=lambda *a, **k: connection)["status"]) + self.assertTrue(connection.closed) + + def test_bridge_auth_and_malformed_report(self): + for replies in [[{"type": "auth_invalid", "message": "SECRET"}], + [{"type": "auth_ok"}, {"type": "result", "id": 1, "success": True, "result": {"schema_version": 2}}]]: + connection = FakeConnection(replies) + result = fetch_references("test", connect=lambda *a, **k: connection) + self.assertEqual("unavailable", result["status"]) + self.assertNotIn("SECRET", json.dumps(result)) + self.assertTrue(connection.closed) + + def test_indirect_device_and_area_use_pagination_and_summary(self): + audit = audit_registry_snapshot({"entities": [{"entity_id": "light.a", "device_id": "a" * 32}], + "devices": [{"id": "a" * 32, "area_id": "kitchen"}]}) + audit.references = analyzer.analyze([source({"entity_id": "light.a", "device_id": "a" * 32, "area_id": "kitchen"})], {}) + review = selection_review(audit, ["light.a"]) + self.assertEqual(3, review["count"]) + scan = ScanResult(id="test", started_at="now", registry_audit=audit) + page = _paged_scan_items(scan, "references", {"entity_id": ["light.a"], "limit": ["1"]}) + self.assertEqual(3, page["total"]) + self.assertTrue(page["has_more"]) + self.assertEqual(1, len(page["items"])) + self.assertNotIn("references", _scan_summary(scan)["registry_audit"]["references"]) + self.assertNotIn("sources", _scan_summary(scan)["registry_audit"]["references"]) + self.assertEqual(0, _paged_scan_items(scan, "references", {"bundle_id": ["unknown"]})["total"]) + + def test_exports_and_plan_preserve_references(self): + audit = audit_registry_snapshot({"entities": [{"entity_id": "light.a"}]}) + audit.entity_workspace = {"items": [{"entity_id": "light.a", "selectable_for_plan": True}]} + audit.references = analyzer.analyze([source({"entity_id": "light.a"})], {}) + audit.references["references"][0]["source_name"] = "=FORMULA" + scan = ScanResult(id="test", started_at="now", status="completed", registry_audit=audit) + with tempfile.TemporaryDirectory() as temp: + plan = PlanManager(Path(temp)).create(scan, Settings(), selected_ids=[], selected_bundle_ids=[], + selected_entity_ids=["light.a"], backup_choice="verified") + self.assertEqual(1, plan["reference_review"]["count"]) + self.assertIn("## Reference checks", _markdown(plan, "en")) + self.assertIn("## Referentiecontrole", _markdown(plan, "nl")) + path = Path(temp) / "report.csv" + _write_csv(scan, path) + self.assertIn("'=FORMULA", path.read_text(encoding="utf-8-sig")) + self.assertIn("$.entity_id", "\n".join(markdown_lines(audit.references))) diff --git a/hass_cleaner/tests/test_reporting.py b/hass_cleaner/tests/test_reporting.py index 747b789..2bf282f 100644 --- a/hass_cleaner/tests/test_reporting.py +++ b/hass_cleaner/tests/test_reporting.py @@ -84,7 +84,7 @@ def test_all_report_formats_are_written_with_execution_metadata(self) -> None: self.assertEqual({"json", "csv", "md"}, set(paths)) payload = json.loads(paths["json"].read_text(encoding="utf-8")) - self.assertEqual(11, payload["schema_version"]) + self.assertEqual(12, payload["schema_version"]) self.assertNotIn("ultra-private-report-value", paths["json"].read_text(encoding="utf-8")) self.assertTrue(payload["audit_only"]) self.assertTrue(payload["execution_locked"]) @@ -157,9 +157,9 @@ def test_073_ui_defaults_to_action_and_caps_large_bundle_details(self) -> None: self.assertEqual(9, html.count('aria-labelledby="')) self.assertIn("initializeTabAccessibility", javascript) self.assertIn('event.key === "ArrowRight"', javascript) - self.assertIn('assets/styles.css?v=1.0.2', html) - self.assertIn('assets/i18n.js?v=1.0.2', html) - self.assertIn('assets/app.js?v=1.0.2', html) + from hass_cleaner import __version__ + for asset in ('styles.css', 'i18n.js', 'app.js'): + self.assertIn(f'assets/{asset}?v={__version__}', html) self.assertIn("Persistently unavailable", translations) self.assertIn("Local entity choice saved", translations) diff --git a/hass_cleaner/tests/test_server.py b/hass_cleaner/tests/test_server.py index 286c1f9..d7dcfe2 100644 --- a/hass_cleaner/tests/test_server.py +++ b/hass_cleaner/tests/test_server.py @@ -60,6 +60,25 @@ def test_health_and_status(self) -> None: self.assertFalse(payload["file_execution_enabled"]) self.assertFalse(payload["registry_execution_enabled"]) + def test_reference_endpoint_is_paginated_and_filters_without_mutation(self): + from hass_cleaner.scanner import ScanResult + from hass_cleaner.registry_audit import RegistryAudit + from unittest.mock import patch + ref = {"source_id": "automation.test", "source_kind": "automation", "source_name": "Test", + "target_type": "entity", "target_id": "light.missing", "location": "$.entity_id", "missing": True} + scan = ScanResult(id="referencetest", started_at="now", registry_audit=RegistryAudit(status="completed", references={ + "status": "completed", "sources": [], "summary": {}, "references": [ref, dict(ref, missing=False)]})) + with patch.object(self.server.state.scan_manager, "get", return_value=scan): + status, page = self.request("/api/scans/referencetest/references?limit=1") + self.assertEqual(200, status) + self.assertEqual(2, page["total"]) + self.assertEqual({"automation": 1}, page["source_counts"]) + self.assertEqual(1, len(page["items"])) + self.assertTrue(page["has_more"]) + self.assertEqual(1, self.request("/api/scans/referencetest/references?status=missing")[1]["total"]) + self.assertEqual(0, self.request("/api/scans/referencetest/references?q=absent")[1]["total"]) + self.assertEqual(2, len(scan.registry_audit.references["references"])) + def test_health_check_is_silent_but_other_requests_are_logged(self) -> None: output = io.StringIO() with redirect_stdout(output): @@ -120,8 +139,9 @@ def test_frontend_cache_policy_separates_shell_and_versioned_assets(self) -> Non with urllib.request.urlopen(f"{self.base}/", timeout=5) as response: html = response.read().decode("utf-8") self.assertEqual("no-cache", response.headers["Cache-Control"]) - self.assertIn("assets/app.js?v=1.0.2", html) - with urllib.request.urlopen(f"{self.base}/assets/app.js?v=1.0.2", timeout=5) as response: + from hass_cleaner import __version__ + self.assertIn(f"assets/app.js?v={__version__}", html) + with urllib.request.urlopen(f"{self.base}/assets/app.js?v={__version__}", timeout=5) as response: response.read() self.assertEqual("public, max-age=31536000, immutable", response.headers["Cache-Control"]) diff --git a/hass_cleaner/web/assets/app.js b/hass_cleaner/web/assets/app.js index c7af34f..efa2033 100644 --- a/hass_cleaner/web/assets/app.js +++ b/hass_cleaner/web/assets/app.js @@ -40,6 +40,101 @@ function englishInterface() { return window.HassCleanerI18n?.locale === "en"; } +function referenceText(en, nl) { return englishInterface() ? en : nl; } + +function referenceWarning() { + return referenceText("Scan snapshot only. Device and area targets indicate potential indirect use. Dynamic templates, blueprints and custom cards may hide dependencies. No references found does not mean removal is safe.", "Alleen een scanmomentopname. Apparaat- en ruimtedoelen tonen mogelijk indirect gebruik. Dynamische templates, blueprints en aangepaste kaarten kunnen verwijzingen verbergen. Geen verwijzingen gevonden betekent niet dat verwijderen veilig is."); +} + +function referenceStatus(status) { + return ({completed: referenceText("Static check completed", "Statische controle voltooid"), + partial: referenceText("Partial coverage", "Gedeeltelijke dekking"), + starting: referenceText("Home Assistant is starting", "Home Assistant wordt gestart")})[status] + || referenceText("Reference checks unavailable — install or check Hass-Cleaner Companion, then scan again.", "Referentiecontrole niet beschikbaar — installeer of controleer Hass-Cleaner Companion en scan opnieuw."); +} + +function referenceRows(items) { + return `
${escapeHtml(r.target_id)} (${escapeHtml(r.target_type)})${r.verification === "unavailable" ? ` — ${referenceText("verification unavailable", "controle niet beschikbaar")}` : r.missing ? ` — ${referenceText("missing", "ontbreekt")}` : ""}${escapeHtml(r.location)}${escapeHtml(referenceStatus(report.status))} · ${escapeHtml(date)}
${referenceWarning()}
`; +} + +function renderReferenceOverview() { + const panel = $("#reference-checks"); + if (!panel) return; + const report = state.registryAudit?.references || {}; + const summary = report.summary || {}; + panel.innerHTML = `${referenceHeader(report)}${summary.sources || 0} ${referenceText("sources", "bronnen")} · ${summary.references || 0} ${referenceText("references", "verwijzingen")} · ${summary.missing || 0} ${referenceText("missing targets", "ontbrekende doelen")} · ${summary.partial_sources || 0} ${referenceText("sources with limited coverage", "bronnen met beperkte dekking")}
${referenceText("Live issues: Home Assistant → Settings → System → Repairs. The companion checks every five minutes; run a new app scan to update this snapshot.", "Actuele meldingen: Home Assistant → Instellingen → Systeem → Reparaties. De companion controleert iedere vijf minuten; start een nieuwe appscan om deze momentopname bij te werken.")}
${referenceText("Companion setup and limitations", "Companion installeren en beperkingen")}`; + if (state.scan && ["completed", "partial"].includes(report.status)) { + const details = document.createElement("details"); + const summaryEl = document.createElement("summary"); + summaryEl.textContent = referenceText("Show references and coverage", "Verwijzingen en dekking tonen"); + const content = document.createElement("div"); + details.append(summaryEl, content); + details.addEventListener("toggle", () => { + if (details.open && !content.dataset.loaded) { + content.dataset.loaded = "true"; + loadReferencePanel(content, {status: "missing"}, 0, true); + } + }); + panel.append(details); + } +} + +async function loadReferencePanel(panel, filters = {}, offset = 0, overview = false) { + if (!panel || !state.scan) return; + const scanId = state.scan.id; + const ticket = String(Number(panel.dataset.request || 0) + 1); + panel.dataset.request = ticket; + panel.textContent = referenceText("Loading references…", "Verwijzingen ophalen…"); + try { + const query = new URLSearchParams({...filters, offset, limit: 100}); + const report = await api(`api/scans/${scanId}/references?${query}`); + if (!panel.isConnected || state.scan?.id !== scanId || panel.dataset.request !== ticket) return; + panel.innerHTML = `${referenceHeader(report)}${report.total} ${referenceText("matching references", "bijbehorende verwijzingen")} · ${report.total ? offset + 1 : 0}–${offset + report.items.length}
${referenceRows(report.items)}`; + const sourceLabels = {automation: referenceText("automations", "automatiseringen"), script: referenceText("scripts", "scripts"), dashboard: referenceText("dashboards", "dashboards"), scene: referenceText("scenes", "scènes"), group: referenceText("groups", "groepen"), helper: referenceText("helpers", "helpers"), template: referenceText("template sources", "templatebronnen"), energy: referenceText("energy configurations", "energieconfiguraties"), statistics: referenceText("statistics configurations", "statistiekconfiguraties")}; + const usage = document.createElement("p"); + usage.textContent = Object.entries(report.source_counts || {}).sort().map(([kind, count]) => `${count} ${sourceLabels[kind] || kind}`).join(" · "); + panel.append(usage); + if (overview) { + const button = document.createElement("button"); + button.className = "button button-ghost"; + button.textContent = filters.status === "missing" ? referenceText("Show all references", "Alle verwijzingen tonen") : referenceText("Show missing targets", "Ontbrekende doelen tonen"); + button.addEventListener("click", () => loadReferencePanel(panel, {status: filters.status === "missing" ? "all" : "missing"}, 0, true)); + panel.append(button); + const coverage = document.createElement("details"); + coverage.innerHTML = `${review.count || 0} ${referenceText("references may be affected by this selection. Review these sources before proceeding.", "verwijzingen kunnen door deze selectie worden geraakt. Controleer deze bronnen voordat je doorgaat.")}
${referenceRows((review.references || []).slice(0, 100))}${(review.count || 0) > 100 ? `${referenceText("First 100 shown; all references are included in the cleanup JSON and Markdown downloads.", "Eerste 100 getoond; alle verwijzingen staan in de JSON- en Markdown-downloads van de opschoning.")}
` : ""}`; +} + +window.addEventListener("hass-cleaner-language", () => { + renderReferenceOverview(); + if (state.latestPlan) renderPlanReferences(state.latestPlan.plan?.reference_review || {}); + if ($("#entity-references") && state.activeEntity) loadReferencePanel($("#entity-references"), {entity_id: state.activeEntity.entity_id}); + if ($("#bundle-references") && state.activeBundle) loadReferencePanel($("#bundle-references"), {bundle_id: state.activeBundle.id}); +}); + function interfaceLocale() { return englishInterface() ? "en-GB" : "nl-NL"; } @@ -285,6 +380,7 @@ function finishScanSummary(scan, showCompletionToast = false) { renderMetrics(scan); renderRecipes(); renderRegistryAudit(); + renderReferenceOverview(); const persistenceErrors = state.registryAudit?.entity_workspace?.persistence_errors || []; if (persistenceErrors.length) showToast(persistenceErrors[0], true); $("#select-all-safe").disabled = !(state.guidance?.safe_recipes || []).length; @@ -706,6 +802,8 @@ async function openEntity(entityId) { const signals = Object.keys(item.connectivity_signals || {}).length ? escapeHtml(JSON.stringify(item.connectivity_signals)) : "Geen integratiespecifieke signalen"; $("#entity-dialog-content").innerHTML = `${escapeHtml(item.reason)}
Nog nodig: ${escapeHtml(item.evidence_needed || "Controleer duur, herhaalde metingen en officiële relaties.")}
Lokale keuze: ${escapeHtml(item.decision || "follow")}${item.decision_until ? ` tot ${escapeHtml(new Date(item.decision_until).toLocaleString(interfaceLocale()))}` : ""}
unavailable, unknown en problem zijn algemene Home Assistant-statussen. Hass-Cleaner geeft context en advies; jij bepaalt wat aan de opschoning wordt toegevoegd en accepteert het risico bij uitvoering.