Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
__pycache__/
*.py[cod]
.venv/
.venv-reference-tests/
dist/
venv/
.mypy_cache/
.ruff_cache/
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
144 changes: 144 additions & 0 deletions custom_components/hass_cleaner/__init__.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions custom_components/hass_cleaner/config_flow.py
Original file line number Diff line number Diff line change
@@ -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({}))
13 changes: 13 additions & 0 deletions custom_components/hass_cleaner/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading