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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Automation Inspector

Automation Inspector is a read-only **Home Assistant App** that audits every loaded automation and, when available, every UI-managed automation in `automations.yaml`.
Automation Inspector is a read-only **Home Assistant App** that audits every loaded automation and script and, when available, every UI-managed automation in `automations.yaml`.

It resolves modern Home Assistant targets, checks dependency health, validates automation syntax, identifies Home Assistant 2026.7 migrations, and surfaces recent trace failures. All analysis runs locally inside Home Assistant; there is no telemetry or external data service.
It resolves modern Home Assistant targets, checks dependency health, validates automation and script syntax, identifies Home Assistant 2026.7 migrations, and surfaces recent trace failures. All analysis runs locally inside Home Assistant; there is no telemetry or external data service.

> [!IMPORTANT]
> Version 1.0.0 is a breaking upgrade. It requires Home Assistant 2026.7.0 or newer, removes unauthenticated host-port access, and drops `armv7`. See [Migrating from 0.4.x](#migrating-from-04x).
Expand Down Expand Up @@ -31,6 +31,7 @@ _Screenshots use synthetic demo data; no Home Assistant instance data is include
## Highlights

- **Complete automation config** — reads each loaded automation through the canonical `automation/config` WebSocket API instead of relying on state attributes.
- **Script inspection** — reads loaded scripts through Home Assistant and checks their dependencies, actions, compatibility, and traces alongside automations.
- **Current target model** — understands entity, device, area, floor, and label targets.
- **Purpose-aware resolution** — filters resolved entities using the trigger, condition, or action target metadata Home Assistant itself publishes.
- **Runtime-aware templates** — preserves templated target values as runtime-resolved metadata without reporting false missing entities.
Expand Down Expand Up @@ -122,6 +123,7 @@ One authenticated WebSocket connection batches:
- entity, device, area, floor, and label registries;
- entity integration sources and service descriptions;
- loaded automation configurations;
- loaded script configurations;
- trigger and condition platform descriptions;
- target extraction and config validation requests;
- trace summaries and failed trace details.
Expand All @@ -130,7 +132,7 @@ One authenticated WebSocket connection batches:

## Report semantics

An automation needs attention when one or more of these conditions apply:
An automation or script needs attention when one or more of these conditions apply:

- an entity reference is missing, unavailable, unknown, or disabled;
- a device, area, floor, or label target no longer exists;
Expand All @@ -139,7 +141,7 @@ An automation needs attention when one or more of these conditions apply:
- the latest stored run failed or contains template errors;
- an entry exists in `automations.yaml` but did not load.

“Unreferenced helpers” are cleanup candidates, not deletion instructions. A helper can still be used by scripts, dashboards, templates, integrations, or external clients.
“Unreferenced helpers” are cleanup candidates, not deletion instructions. A helper can still be used by dashboards, templates, integrations, or external clients.

## HTTP API

Expand Down
8 changes: 4 additions & 4 deletions automation_inspector/DOCS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Automation Inspector App documentation

Automation Inspector performs read-only health analysis of Home Assistant automations. Open it from the Home Assistant sidebar after starting the App.
Automation Inspector performs read-only health analysis of Home Assistant automations and scripts. Open it from the Home Assistant sidebar after starting the App.

## Configuration

Expand All @@ -9,7 +9,7 @@ Automation Inspector performs read-only health analysis of Home Assistant automa
| `refresh_interval` | 300 | Seconds between background inspections |
| `request_timeout` | 15 | Timeout for each WebSocket response |
| `include_disabled` | `true` | Analyze automations that are turned off |
| `inspect_traces` | `true` | Retrieve details for recent failed traces |
| `inspect_traces` | `true` | Retrieve details for recent failed automation and script traces |
| `scan_automations_file` | `true` | Find UI-managed automations that did not load |

Restart the App after changing options.
Expand All @@ -21,7 +21,7 @@ Restart the App after changing options.
- **Unavailable / unknown** — Home Assistant has a state object, but its current state is unhealthy.
- **Not loaded** — the automation exists in `automations.yaml` but has no runtime automation entity.
- **Unresolved target** — a referenced device, area, floor, or label no longer exists.
- **Runtime target** — a Jinja template such as `{{ sonos_speaker }}` that Home Assistant resolves only when the automation runs; this is informational, not a failure.
- **Runtime target** — a Jinja template such as `{{ sonos_speaker }}` that Home Assistant resolves only when the automation or script runs; this is informational, not a failure.
- **Compatibility** — Home Assistant validation failed or the configuration uses a removed/deprecated construct.
- **Trace failure** — the latest retained execution ended in an error or contains template errors.

Expand Down Expand Up @@ -49,7 +49,7 @@ This is intentional in 1.0.0. Automation definitions and entity states are sensi

### Unreferenced helper appears in use elsewhere

The helper list considers automations only. Scripts, dashboards, integrations, templates, and external clients are outside its scope. Always verify before deleting a helper.
The helper list considers inspected automations and scripts only. Dashboards, integrations, templates, and external clients are outside its scope. Always verify before deleting a helper.

## Data handling

Expand Down
91 changes: 72 additions & 19 deletions automation_inspector/app/dependency_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,28 +185,32 @@ def _latest_traces(traces: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]
for trace in traces:
if trace.get("not_triggered"):
continue
domain = str(trace.get("domain") or "automation")
item_id = trace.get("item_id")
timestamp = trace.get("timestamp")
if not isinstance(item_id, str) or not isinstance(timestamp, dict):
continue
key = f"{domain}:{item_id}"
start = str(timestamp.get("start", ""))
previous = latest.get(item_id)
previous = latest.get(key)
previous_start = str((previous or {}).get("timestamp", {}).get("start", ""))
if previous is None or start > previous_start:
latest[item_id] = trace
latest[key] = trace
return latest


def _trace_info(
domain: str,
config_id: str | None,
latest: Mapping[str, dict[str, Any]],
details: Mapping[str, dict[str, Any]],
) -> dict[str, Any] | None:
if not config_id or config_id not in latest:
trace_key = f"{domain}:{config_id}" if config_id else None
if not trace_key or trace_key not in latest:
return None
summary = latest[config_id]
summary = latest[trace_key]
template_errors: list[str] = []
detail = details.get(config_id)
detail = details.get(trace_key)
if isinstance(detail, dict):
steps = detail.get("trace")
if isinstance(steps, dict):
Expand Down Expand Up @@ -328,6 +332,7 @@ def _target_rows(

def _analyze_automation(
*,
domain: str,
key: str,
state: dict[str, Any] | None,
configs: list[tuple[str, dict[str, Any]]],
Expand Down Expand Up @@ -383,7 +388,7 @@ def _analyze_automation(
}
)

trace = _trace_info(config_id, latest_traces, snapshot.trace_details)
trace = _trace_info(domain, config_id, latest_traces, snapshot.trace_details)
trace_is_issue = bool(
trace
and (
Expand All @@ -396,11 +401,12 @@ def _analyze_automation(
compatibility_errors = sum(
1 for finding in compatibility if finding["severity"] in {"error", "warning"}
)
state_value = str(state.get("state")) if state is not None else None
if not loaded:
status = "not_loaded"
elif state is not None and str(state.get("state")) == "on":
elif state_value == "on" or (domain == "script" and state_value != "unavailable"):
status = "enabled"
elif state is not None and str(state.get("state")) == "unavailable":
elif state_value == "unavailable":
status = "unavailable"
else:
status = "disabled"
Expand All @@ -409,6 +415,8 @@ def _analyze_automation(
warnings: list[str] = []
if loaded and key in snapshot.automation_config_errors:
warnings.append(snapshot.automation_config_errors[key])
if loaded and domain == "script" and key in snapshot.script_config_errors:
warnings.append(snapshot.script_config_errors[key])
if "use_blueprint" in primary_config:
warnings.append("Blueprint analysis is limited to its configured inputs.")

Expand All @@ -417,6 +425,8 @@ def _analyze_automation(
)
return {
"entity_id": key if loaded else None,
"domain": domain,
"item_type": "automation" if domain == "automation" else "script",
"friendly_name": friendly_name,
"enabled": status == "enabled",
"loaded": loaded,
Expand Down Expand Up @@ -482,6 +492,7 @@ def build_inspection(snapshot: SourceSnapshot, settings: Settings) -> dict[str,

latest_traces = _latest_traces(snapshot.traces)
automations: dict[str, dict[str, Any]] = {}
scripts: dict[str, dict[str, Any]] = {}
matched_file_keys: set[str] = set()
for entity_id, state in sorted(state_map.items()):
if not entity_id.startswith("automation."):
Expand All @@ -508,6 +519,7 @@ def build_inspection(snapshot: SourceSnapshot, settings: Settings) -> dict[str,
if not configs:
configs.append((f"attributes:{entity_id}", attributes))
automations[entity_id] = _analyze_automation(
domain="automation",
key=entity_id,
state=state,
configs=configs,
Expand All @@ -520,13 +532,43 @@ def build_inspection(snapshot: SourceSnapshot, settings: Settings) -> dict[str,
latest_traces=latest_traces,
)

for entity_id, state in sorted(state_map.items()):
if not entity_id.startswith("script."):
continue
attributes = state.get("attributes", {})
if not isinstance(attributes, dict):
attributes = {}
raw_config_id = attributes.get("id") or entity_id.split(".", 1)[1]
config_id = str(raw_config_id) if raw_config_id is not None else None
runtime_config = snapshot.script_configs.get(entity_id)

script_configs: list[tuple[str, dict[str, Any]]] = []
if runtime_config:
script_configs.append((f"runtime:{entity_id}", runtime_config))
else:
script_configs.append((f"attributes:{entity_id}", attributes))
scripts[entity_id] = _analyze_automation(
domain="script",
key=entity_id,
state=state,
configs=script_configs,
config_id=config_id,
loaded=True,
snapshot=snapshot,
state_map=state_map,
registry_map=registry_map,
known_domains=known_domains,
latest_traces=latest_traces,
)

for file_automation in snapshot.file_automations:
if file_automation.key in matched_file_keys:
continue
key = f"unloaded:{file_automation.config_id or file_automation.index}"
if key in automations:
key = f"{key}:{file_automation.index}"
automations[key] = _analyze_automation(
domain="automation",
key=key,
state=None,
configs=[(file_automation.key, file_automation.config)],
Expand All @@ -540,7 +582,9 @@ def build_inspection(snapshot: SourceSnapshot, settings: Settings) -> dict[str,
)

all_referenced = {
entity["id"] for automation in automations.values() for entity in automation["entities"]
entity["id"]
for item in [*automations.values(), *scripts.values()]
for entity in item["entities"]
}
unreferenced_helpers = []
for entity_id in sorted(set(state_map) | set(registry_map)):
Expand All @@ -567,18 +611,18 @@ def build_inspection(snapshot: SourceSnapshot, settings: Settings) -> dict[str,
)

entity_rows = [
entity for automation in automations.values() for entity in automation["entities"]
entity for item in [*automations.values(), *scripts.values()] for entity in item["entities"]
]
compatibility_rows = [
finding
for automation in automations.values()
for finding in automation["compatibility_issues"]
for item in [*automations.values(), *scripts.values()]
for finding in item["compatibility_issues"]
if finding["severity"] in {"error", "warning"}
]
unresolved_targets = sum(
len(target[field])
for automation in automations.values()
for target in automation["targets"]
for item in [*automations.values(), *scripts.values()]
for target in item["targets"]
for field in (
"missing_devices",
"missing_areas",
Expand All @@ -588,12 +632,12 @@ def build_inspection(snapshot: SourceSnapshot, settings: Settings) -> dict[str,
)
trace_failures = sum(
1
for automation in automations.values()
if automation["trace"]
for item in [*automations.values(), *scripts.values()]
if item["trace"]
and (
automation["trace"].get("error")
or automation["trace"].get("template_errors")
or automation["trace"].get("script_execution") in {"error", "failed_max_runs"}
item["trace"].get("error")
or item["trace"].get("template_errors")
or item["trace"].get("script_execution") in {"error", "failed_max_runs"}
)
)
ha_config = snapshot.home_assistant_config
Expand All @@ -608,8 +652,12 @@ def build_inspection(snapshot: SourceSnapshot, settings: Settings) -> dict[str,
},
"summary": {
"automations": len(automations),
"scripts": len(scripts),
"inspected_items": len(automations) + len(scripts),
"enabled": sum(1 for item in automations.values() if item["enabled"]),
"enabled_scripts": sum(1 for item in scripts.values() if item["enabled"]),
"disabled": sum(1 for item in automations.values() if item["status"] == "disabled"),
"disabled_scripts": sum(1 for item in scripts.values() if item["status"] == "disabled"),
"unloaded": sum(1 for item in automations.values() if not item["loaded"]),
"dependency_references": len(entity_rows),
"unique_entities": len({row["id"] for row in entity_rows}),
Expand All @@ -620,13 +668,18 @@ def build_inspection(snapshot: SourceSnapshot, settings: Settings) -> dict[str,
"automations_with_issues": sum(
1 for item in automations.values() if item["issue_count"] > 0
),
"scripts_with_issues": sum(1 for item in scripts.values() if item["issue_count"] > 0),
"items_with_issues": sum(
1 for item in [*automations.values(), *scripts.values()] if item["issue_count"] > 0
),
"compatibility_issues": len(compatibility_rows),
"unresolved_targets": unresolved_targets,
"trace_failures": trace_failures,
"unreferenced_helpers": len(unreferenced_helpers),
"duration_ms": round((time.perf_counter() - started) * 1000, 1),
},
"automations": automations,
"scripts": scripts,
"unreferenced_helpers": unreferenced_helpers,
"orphans": [helper["id"] for helper in unreferenced_helpers],
"warnings": snapshot.warnings,
Expand Down
Loading