"""Diagnostics support for the Maintenance Supporter integration.""" from __future__ import annotations from collections.abc import Mapping from typing import TYPE_CHECKING, Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant from .const import ( CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, UNAVAILABLE_STATES, MaintenanceStatus, TriggerEntityState, ) from .entity.triggers import normalize_entity_ids from .helpers.schedule import read_legacy_fields if TYPE_CHECKING: from . import MaintenanceSupporterConfigEntry # Fields to redact from diagnostics (downloads are admin-accessible and routinely # pasted into public GitHub issues, so anything free-text / identifying is redacted) TO_REDACT = { "checklist", "notes", "documentation_url", "manufacturer", "model", "name", "nfc_tag_id", "notify_service", "responsible_user_id", # HA user UUIDs — redact for parity with responsible_user_id (were leaking). "assignee_pool", "admin_panel_user_ids", "serial_number", # Spare-part supplier/product identifiers (product_url can carry tokens). "product_url", "vendor", "mpn", "gtin", "storage_location", # Per-member avatar overrides (#169): user ids with names/initials. "member_display", # The HA user an on-complete action runs as (a user UUID, bug audit # 2026-09-26) — see also _redact_actions for the action's payload. "configured_by", } REDACTED = "**REDACTED**" def _redact_actions(data: Mapping[str, Any]) -> dict[str, Any]: """Redact the free-form payload of every task's on-complete action. The ``data`` of a service call is whatever the user typed (a message, a phone number, a URL with a token) — while the service name and target stay, they are what one debugs. A blanket ``"data"`` key in TO_REDACT would hit unrelated structures, so this is targeted (bug audit 2026-09-26, SEC-11). """ tasks = data.get(CONF_TASKS) if not isinstance(tasks, dict): return dict(data) new_tasks: dict[str, Any] = {} for task_id, task in tasks.items(): action = task.get("on_complete_action") if isinstance(task, dict) else None if isinstance(action, dict) and action.get("data"): task = {**task, "on_complete_action": {**action, "data": REDACTED}} new_tasks[task_id] = task return {**data, CONF_TASKS: new_tasks} async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: MaintenanceSupporterConfigEntry) -> dict[str, Any]: """Return diagnostics for a config entry.""" is_global = entry.unique_id == GLOBAL_UNIQUE_ID diag: dict[str, Any] = { "entry": { # An object entry's title IS the object name (redacted in the # data below) and its unique id is the slug of that name — both # went out in the clear (bug audit 2026-09-26, SEC-11). "title": entry.title if is_global else REDACTED, "unique_id": entry.unique_id if is_global else REDACTED, "version": entry.version, "is_global": is_global, }, "data": async_redact_data(_redact_actions(entry.data), TO_REDACT), } if is_global: # Global entry diagnostics diag["options"] = async_redact_data(entry.options or {}, TO_REDACT) diag["overview"] = _get_integration_overview(hass) else: # Object entry diagnostics — merge Store dynamic data for stats from .helpers.aggregate import merged_tasks merged_data = dict(entry.data) merged_data[CONF_TASKS] = merged_tasks(entry) diag["statistics"] = _calculate_statistics(merged_data) diag["trigger_status"] = _check_trigger_status(hass, entry.data) diag["data_quality"] = _check_data_quality(entry.data) # Coordinator info runtime_data = getattr(entry, "runtime_data", None) if runtime_data and runtime_data.coordinator: coord = runtime_data.coordinator diag["coordinator"] = { "last_update_success": coord.last_update_success, "last_update_success_time": str(getattr(coord, "last_update_success_time", None)), "update_interval": str(coord.update_interval), } return diag def _get_integration_overview(hass: HomeAssistant) -> dict[str, Any]: """Get an overview of all maintenance objects.""" entries = hass.config_entries.async_entries(DOMAIN) objects = [e for e in entries if e.unique_id != GLOBAL_UNIQUE_ID] total_tasks = 0 overdue = 0 due_soon = 0 for entry in objects: tasks = entry.data.get(CONF_TASKS, {}) total_tasks += len(tasks) runtime_data = getattr(entry, "runtime_data", None) if runtime_data and runtime_data.coordinator and runtime_data.coordinator.data: for task_data in runtime_data.coordinator.data.get("tasks", {}).values(): status = task_data.get("_status") if status == MaintenanceStatus.OVERDUE: overdue += 1 elif status == MaintenanceStatus.DUE_SOON: due_soon += 1 return { "total_objects": len(objects), "total_tasks": total_tasks, "overdue_tasks": overdue, "due_soon_tasks": due_soon, } def _calculate_statistics(data: Mapping[str, Any]) -> dict[str, Any]: """Calculate statistics for a maintenance object.""" tasks = data.get(CONF_TASKS, {}) stats: dict[str, Any] = { "total_tasks": len(tasks), "enabled_tasks": sum(1 for t in tasks.values() if t.get("enabled", True)), "tasks_with_triggers": sum(1 for t in tasks.values() if t.get("trigger_config")), "tasks_by_type": {}, "tasks_by_schedule": {}, "total_history_entries": 0, } for task in tasks.values(): # By type task_type = task.get("type", "unknown") stats["tasks_by_type"][task_type] = stats["tasks_by_type"].get(task_type, 0) + 1 # By schedule (accepts flat v2.6.x or nested `schedule` storage) schedule = read_legacy_fields(task)["schedule_type"] stats["tasks_by_schedule"][schedule] = stats["tasks_by_schedule"].get(schedule, 0) + 1 # History stats["total_history_entries"] += len(task.get("history", [])) return stats def _check_trigger_status(hass: HomeAssistant, data: Mapping[str, Any]) -> list[dict[str, Any]]: """Check the status of all configured triggers.""" results = [] tasks = data.get(CONF_TASKS, {}) for task_id, task in tasks.items(): trigger_config = task.get("trigger_config") if not trigger_config: continue # The shared walk (compound conditions incl. their nested # trigger_config, which this copy missed — DRY audit 2026-09-26 B). entity_ids = normalize_entity_ids(trigger_config) if not entity_ids: continue for eid in entity_ids: state = hass.states.get(eid) if state is None: entity_health = TriggerEntityState.MISSING elif state.state in UNAVAILABLE_STATES: entity_health = TriggerEntityState.UNAVAILABLE else: entity_health = TriggerEntityState.AVAILABLE results.append( { "task_id": task_id, "trigger_entity": eid, "trigger_type": trigger_config.get("type"), "entity_available": state is not None, # raw entity_state intentionally omitted — it can carry PII # (e.g. a device_tracker / person location); entity_health # gives the available/unavailable/missing status for debugging. "entity_health": entity_health, } ) return results def _check_data_quality(data: Mapping[str, Any]) -> list[str]: """Check data quality and return warnings.""" warnings = [] obj = data.get(CONF_OBJECT, {}) tasks = data.get(CONF_TASKS, {}) if not obj.get("name"): warnings.append("Object has no name") if not tasks: warnings.append("Object has no tasks defined") for task_id, task in tasks.items(): if not task.get("name"): warnings.append(f"Task {task_id} has no name") sched = read_legacy_fields(task) if sched["schedule_type"] == "time_based" and not sched["interval_days"]: # Reference by id, not name — these warnings aren't redacted. warnings.append(f"Task {task_id} is time-based but has no interval") trigger = task.get("trigger_config") # A trigger stored with only the plural entity_ids has an entity too. if trigger and trigger.get("type") != "compound" and not normalize_entity_ids(trigger): warnings.append(f"Task {task_id} has trigger config but no entity") return warnings