"""Repairs support for the Maintenance Supporter integration. Provides a multi-step repair flow for missing trigger entities with three options: 1. Replace — select a new entity to use as trigger 2. Remove — remove the trigger entirely (convert to time_based/manual) 3. Dismiss — close the issue (will reappear on next check) """ from __future__ import annotations import logging from typing import Any import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import selector from .config_flow_trigger import TRIGGER_ENTITY_DOMAINS from .const import ( CONF_ADMIN_PANEL_USER_IDS, CONF_TASKS, DOMAIN, HistoryEntryType, ScheduleType, ) from .helpers.schedule import ( FLAT_RECURRENCE_KEYS, normalize_task_storage, read_legacy_fields, ) from .models.maintenance_task import MaintenanceTask _LOGGER = logging.getLogger(__name__) # ─── Repair-flow guard helpers ────────────────────────────────────────── # Used by all three RepairsFlow subclasses below to centralise the # "issue references an entry/task that may have been deleted since the # issue was created" guard pattern. See StaleActionEntityRepairFlow, # MissingTriggerEntityRepairFlow, OrphanAdminPanelUserRepairFlow. def _entry_for_issue(hass: HomeAssistant, issue_data: dict[str, Any] | None) -> Any: """Resolve the per-object ConfigEntry referenced by ``issue_data['entry_id']``. Returns the entry, or ``None`` if the id is missing or the entry has been deleted since the issue was created. Repair-flow steps should abort with ``reason="entry_gone"`` on ``None``. """ eid = str((issue_data or {}).get("entry_id", "")) if not eid: return None return hass.config_entries.async_get_entry(eid) def _entry_has_task(entry: Any, task_id: str | None) -> bool: """True iff ``entry`` exists and ``entry.data[CONF_TASKS]`` contains ``task_id``. Stricter variant of :func:`_entry_for_issue` for repair flows whose target is a specific task within an entry — used by :class:`MissingTriggerEntityRepairFlow` to abort with ``reason="task_deleted"`` when the task has been removed. """ if entry is None or not task_id: return False return task_id in entry.data.get(CONF_TASKS, {}) def _replace_entity_in_dict(cfg: dict[str, Any], old: str, new: str) -> dict[str, Any]: """Return a copy of ``cfg`` with entity_id/entity_ids replaced if matching.""" cfg = dict(cfg) eids = cfg.get("entity_ids") if isinstance(eids, list) and old in eids: cfg["entity_ids"] = [new if e == old else e for e in eids] if cfg.get("entity_id") == old: cfg["entity_id"] = new return cfg def _replace_entity_in_condition(cond: dict[str, Any], old: str, new: str) -> dict[str, Any]: """Replace ``old`` entity with ``new`` in a compound trigger condition. Conditions may have entity references at top level OR in a nested ``trigger_config`` sub-dict. """ cond = _replace_entity_in_dict(cond, old, new) nested = cond.get("trigger_config") if isinstance(nested, dict): cond["trigger_config"] = _replace_entity_in_dict(nested, old, new) return cond def _strip_entity_from_dict(cfg: dict[str, Any], target: str) -> tuple[dict[str, Any], bool]: """Strip ``target`` from a flat trigger config dict. Returns (modified copy, has_remaining_entity). """ cfg = dict(cfg) eids = cfg.get("entity_ids") if isinstance(eids, list): eids = [e for e in eids if e != target] if eids: cfg["entity_ids"] = eids if cfg.get("entity_id") == target: cfg["entity_id"] = eids[0] return cfg, True cfg.pop("entity_ids", None) if cfg.get("entity_id") == target: cfg.pop("entity_id", None) has_remaining = bool(cfg.get("entity_id") or cfg.get("entity_ids")) return cfg, has_remaining def _strip_entity_from_condition(cond: dict[str, Any], target: str) -> tuple[dict[str, Any], bool]: """Strip ``target`` from a compound condition. Returns (modified copy, keep_condition). The condition is kept if it still references at least one entity (top-level or nested). """ cond, top_has = _strip_entity_from_dict(cond, target) nested = cond.get("trigger_config") nested_has = False if isinstance(nested, dict): nested_stripped, nested_has = _strip_entity_from_dict(nested, target) cond["trigger_config"] = nested_stripped return cond, top_has or nested_has class MissingTriggerEntityRepairFlow(RepairsFlow): """Handle repair for a missing trigger entity. ``self.data`` is populated by ``async_create_issue(data=...)`` and contains:: { "entry_id": str, "task_id": str, "task_name": str, "object_name": str, "entity_id": str, # the missing entity } """ async def async_step_init(self, user_input: dict[str, str] | None = None) -> data_entry_flow.FlowResult: """Show a menu with repair options.""" issue_data = self.data or {} return self.async_show_menu( step_id="init", menu_options=["replace_entity", "remove_trigger", "dismiss"], description_placeholders={ "entity_id": str(issue_data.get("entity_id", "unknown")), "task_name": str(issue_data.get("task_name", "unknown")), "object_name": str(issue_data.get("object_name", "unknown")), }, ) async def async_step_replace_entity(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult: """Let the user pick a replacement entity.""" issue_data = self.data or {} if user_input is not None: new_entity_id = user_input["new_entity_id"] issue_data = self.data or {} entry = _entry_for_issue(self.hass, issue_data) if not _entry_has_task(entry, str(issue_data.get("task_id") or "")): return self.async_abort(reason="task_deleted") await self._replace_trigger_entity(new_entity_id) return self.async_create_entry(data={}) return self.async_show_form( step_id="replace_entity", data_schema=vol.Schema( { vol.Required("new_entity_id"): selector.EntitySelector( selector.EntitySelectorConfig( domain=TRIGGER_ENTITY_DOMAINS, multiple=False, ) ), } ), description_placeholders={ "entity_id": str(issue_data.get("entity_id", "unknown")), "task_name": str(issue_data.get("task_name", "unknown")), "object_name": str(issue_data.get("object_name", "unknown")), }, ) async def async_step_remove_trigger(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult: """Confirm removal of the trigger (convert to time_based or manual).""" issue_data = self.data or {} if user_input is not None: issue_data = self.data or {} entry = _entry_for_issue(self.hass, issue_data) if not _entry_has_task(entry, str(issue_data.get("task_id") or "")): return self.async_abort(reason="task_deleted") await self._remove_trigger() return self.async_create_entry(data={}) return self.async_show_form( step_id="remove_trigger", data_schema=vol.Schema({}), description_placeholders={ "entity_id": str(issue_data.get("entity_id", "unknown")), "task_name": str(issue_data.get("task_name", "unknown")), "object_name": str(issue_data.get("object_name", "unknown")), }, ) async def async_step_dismiss(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult: """Dismiss the issue (it will reappear if entity is still missing).""" return self.async_create_entry(data={}) # --- Helpers --- async def _replace_trigger_entity(self, new_entity_id: str) -> None: """Replace the trigger entity in config entry data and reload. For multi-entity triggers, replaces the specific missing entity within the entity_ids list. """ issue_data = self.data or {} entry_id = str(issue_data.get("entry_id", "")) task_id = issue_data.get("task_id") old_entity_id = str(issue_data.get("entity_id", "")) if not entry_id or not task_id: _LOGGER.error("Repair flow missing entry_id or task_id in issue data") return entry = self.hass.config_entries.async_get_entry(entry_id) if entry is None: _LOGGER.error("Config entry %s not found", entry_id) return # Read static task data from ConfigEntry tasks_data = dict(entry.data.get(CONF_TASKS, {})) task_dict = dict(tasks_data[task_id]) trigger_config = dict(task_dict.get("trigger_config", {})) if trigger_config.get("type") == "compound": # Recurse into conditions new_conditions = [ _replace_entity_in_condition(cond, old_entity_id, new_entity_id) for cond in trigger_config.get("conditions", []) ] trigger_config["conditions"] = new_conditions else: # Flat trigger: update entity_ids list if present entity_ids = trigger_config.get("entity_ids", []) if entity_ids and old_entity_id in entity_ids: entity_ids = [new_entity_id if eid == old_entity_id else eid for eid in entity_ids] trigger_config["entity_ids"] = entity_ids # Update entity_id (for backwards compat / single-entity) if trigger_config.get("entity_id") == old_entity_id: trigger_config["entity_id"] = new_entity_id if entity_ids: trigger_config["entity_id"] = entity_ids[0] # Reset runtime values trigger_config.pop("trigger_baseline_value", None) trigger_config.pop("trigger_change_count", None) task_dict["trigger_config"] = trigger_config tasks_data[task_id] = task_dict # Write static changes to ConfigEntry new_data = dict(entry.data) new_data[CONF_TASKS] = tasks_data self.hass.config_entries.async_update_entry(entry, data=new_data) # Add history entry via Store (dynamic state) rd = getattr(entry, "runtime_data", None) store = getattr(rd, "store", None) if rd else None if store is not None: merged = store.merge_task_data(task_id, task_dict) task = MaintenanceTask.from_dict(merged) task.add_history_entry( entry_type=HistoryEntryType.TRIGGER_REPLACED, notes=f"Trigger entity replaced: {old_entity_id} → {new_entity_id}", ) td = task.to_dict() store.set_history(task_id, td.get("history", [])) store.clear_trigger_runtime(task_id) store.async_delay_save() else: # Legacy: full task roundtrip via ConfigEntry task = MaintenanceTask.from_dict(task_dict) task.add_history_entry( entry_type=HistoryEntryType.TRIGGER_REPLACED, notes=f"Trigger entity replaced: {old_entity_id} → {new_entity_id}", ) tasks_data[task_id] = task.to_dict() new_data[CONF_TASKS] = tasks_data self.hass.config_entries.async_update_entry(entry, data=new_data) # Reload entry so the trigger re-initialises with the new entity await self.hass.config_entries.async_reload(entry_id) _LOGGER.info( "Trigger entity for task '%s' replaced: %s → %s", issue_data.get("task_name"), old_entity_id, new_entity_id, ) async def _remove_trigger(self) -> None: """Remove the missing entity from the trigger. For multi-entity triggers, removes only the specific entity from the entity_ids list. If only one entity remains (or was the only one), removes the entire trigger and converts to time_based or manual. """ issue_data = self.data or {} entry_id = str(issue_data.get("entry_id", "")) task_id = issue_data.get("task_id") missing_entity_id = str(issue_data.get("entity_id", "")) if not entry_id or not task_id: _LOGGER.error("Repair flow missing entry_id or task_id in issue data") return entry = self.hass.config_entries.async_get_entry(entry_id) if entry is None: _LOGGER.error("Config entry %s not found", entry_id) return tasks_data = dict(entry.data.get(CONF_TASKS, {})) task_dict = dict(tasks_data[task_id]) trigger_config = dict(task_dict.get("trigger_config", {})) history_notes: str if trigger_config.get("type") == "compound": history_notes = self._remove_from_compound(task_dict, trigger_config, missing_entity_id) else: history_notes = self._remove_from_flat(task_dict, trigger_config, missing_entity_id) # Write static changes to ConfigEntry (recurrence normalized to nested) tasks_data[task_id] = normalize_task_storage(task_dict) new_data = dict(entry.data) new_data[CONF_TASKS] = tasks_data self.hass.config_entries.async_update_entry(entry, data=new_data) # Add history entry via Store (dynamic state) rd = getattr(entry, "runtime_data", None) store = getattr(rd, "store", None) if rd else None if store is not None: merged = store.merge_task_data(task_id, task_dict) task = MaintenanceTask.from_dict(merged) task.add_history_entry( entry_type=HistoryEntryType.TRIGGER_REMOVED, notes=history_notes, ) td = task.to_dict() store.set_history(task_id, td.get("history", [])) store.clear_trigger_runtime(task_id) store.async_delay_save() else: # Legacy: history via full task roundtrip in ConfigEntry task = MaintenanceTask.from_dict(task_dict) task.add_history_entry( entry_type=HistoryEntryType.TRIGGER_REMOVED, notes=history_notes, ) tasks_data[task_id] = task.to_dict() new_data[CONF_TASKS] = tasks_data self.hass.config_entries.async_update_entry(entry, data=new_data) await self.hass.config_entries.async_reload(entry_id) _LOGGER.info( "Trigger entity %s removed for task '%s'", missing_entity_id, issue_data.get("task_name"), ) def _remove_from_flat( self, task_dict: dict[str, Any], trigger_config: dict[str, Any], missing_entity_id: str, ) -> str: """Remove ``missing_entity_id`` from a flat trigger config. Mutates ``task_dict`` in place. Returns a history notes string. """ entity_ids = trigger_config.get("entity_ids", []) remaining = [eid for eid in entity_ids if eid != missing_entity_id] if remaining: trigger_config["entity_ids"] = remaining trigger_config["entity_id"] = remaining[0] task_dict["trigger_config"] = trigger_config return f"Entity {missing_entity_id} removed from multi-entity trigger. Remaining: {', '.join(remaining)}" old_entity_id = trigger_config.get("entity_id", missing_entity_id) safety_interval = trigger_config.get("interval_days") fields = read_legacy_fields(task_dict) task_dict.pop("trigger_config", None) # Rebuild the recurrence from a flat spec (drop the nested schedule so # the change takes effect; the caller normalizes back to nested). task_dict.pop("schedule", None) for key in FLAT_RECURRENCE_KEYS: task_dict.pop(key, None) if safety_interval or fields["interval_days"]: new_type = ScheduleType.TIME_BASED task_dict["schedule_type"] = new_type task_dict["interval_days"] = fields["interval_days"] or safety_interval task_dict["interval_unit"] = fields["interval_unit"] task_dict["interval_anchor"] = fields["interval_anchor"] else: new_type = ScheduleType.MANUAL task_dict["schedule_type"] = new_type return f"Sensor trigger removed (entity was: {old_entity_id}). Schedule converted to {new_type}." def _remove_from_compound( self, task_dict: dict[str, Any], trigger_config: dict[str, Any], missing_entity_id: str, ) -> str: """Remove ``missing_entity_id`` from a compound trigger config. Walks each condition; if a condition still references at least one entity it is kept (with the missing entity stripped). Otherwise the condition is dropped. Resulting condition count handling: - ``>= 2``: stays compound - ``== 1``: demoted to a flat trigger using the remaining condition - ``== 0``: trigger removed entirely (delegate to flat fallback) Mutates ``task_dict`` in place. Returns a history notes string. """ new_conditions = [] for cond in trigger_config.get("conditions", []): stripped, keep = _strip_entity_from_condition(cond, missing_entity_id) if keep: new_conditions.append(stripped) if len(new_conditions) >= 2: trigger_config["conditions"] = new_conditions task_dict["trigger_config"] = trigger_config return f"Entity {missing_entity_id} removed from compound trigger; {len(new_conditions)} conditions remain." if len(new_conditions) == 1: sole = new_conditions[0] # Conditions may carry their config nested under "trigger_config" nested = sole.get("trigger_config") if isinstance(nested, dict): new_tc = dict(nested) if "type" not in new_tc and sole.get("type"): new_tc["type"] = sole["type"] else: new_tc = dict(sole) task_dict["trigger_config"] = new_tc return ( f"Entity {missing_entity_id} removed; compound trigger " f"demoted to single trigger ({new_tc.get('type', 'unknown')})." ) # 0 conditions remain — fall back to the flat removal path # (uses the original trigger_config to determine schedule_type) return self._remove_from_flat(task_dict, trigger_config, missing_entity_id) class OrphanAdminPanelUserRepairFlow(RepairsFlow): """Repair flow for an admin_panel_user_ids entry pointing at a deleted HA user. Single action: remove the orphaned id from the panel-access list. There's no scenario where keeping an invalid user_id makes sense — if an admin really wants to silence the issue without fixing it, they can use HA's built-in "Ignore issue" UI on the Repairs page. `self.data` is populated by ``async_create_issue(data=...)`` and contains:: { "user_id": str, # the orphaned HA user UUID "entry_id": str, # the global config entry ID } """ async def async_step_init(self, user_input: dict[str, str] | None = None) -> data_entry_flow.FlowResult: """Show a confirmation form, then remove on submit.""" issue_data = self.data or {} if user_input is not None: return await self.async_step_remove_user_id() return self.async_show_form( step_id="init", data_schema=vol.Schema({}), description_placeholders={ "user_id": str(issue_data.get("user_id", "?"))[:8], }, ) async def async_step_remove_user_id(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult: """Remove the orphaned id from admin_panel_user_ids and persist.""" issue_data = self.data or {} entry = _entry_for_issue(self.hass, issue_data) if entry is None: return self.async_abort(reason="entry_gone") target_uid = str(issue_data.get("user_id", "")) ids = list(entry.options.get(CONF_ADMIN_PANEL_USER_IDS, []) or []) if target_uid in ids: ids.remove(target_uid) self.hass.config_entries.async_update_entry( entry, options={**entry.options, CONF_ADMIN_PANEL_USER_IDS: ids}, ) return self.async_create_entry(data={}) class StaleActionEntityRepairFlow(RepairsFlow): """Repair flow for an `on_complete_action.target.entity_id` that no longer resolves to an entity in HA. Three options: 1. Replace — pick a new entity to point the action at 2. Remove — clear `on_complete_action` from the task entirely 3. Ignore — fall through (user uses HA's Ignore-issue button) `self.data` carries:: { "entry_id": str, # the per-object config entry holding the task "task_id": str, # the task whose action references the dead entity "task_name": str, # for the description placeholder "stale_entity": str # the now-missing entity_id } """ async def async_step_init(self, user_input: dict[str, str] | None = None) -> data_entry_flow.FlowResult: return self.async_show_menu( step_id="init", menu_options=["replace_entity", "remove_action"], description_placeholders={ "task_name": str((self.data or {}).get("task_name", "?")), "stale_entity": str((self.data or {}).get("stale_entity", "?")), }, ) async def async_step_replace_entity(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult: if user_input is not None: entry = self._entry() if entry is None: return self.async_abort(reason="entry_gone") new_eid = str(user_input.get("new_entity", "")).strip() if not new_eid: return self.async_abort(reason="no_entity") self._patch_action_entity(entry, new_eid) return self.async_create_entry(data={}) return self.async_show_form( step_id="replace_entity", data_schema=vol.Schema( { vol.Required("new_entity"): selector.EntitySelector(), } ), description_placeholders={ "task_name": str((self.data or {}).get("task_name", "?")), "stale_entity": str((self.data or {}).get("stale_entity", "?")), }, ) async def async_step_remove_action(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult: if user_input is not None: entry = self._entry() if entry is None: return self.async_abort(reason="entry_gone") self._clear_action(entry) return self.async_create_entry(data={}) return self.async_show_form( step_id="remove_action", data_schema=vol.Schema({}), description_placeholders={ "task_name": str((self.data or {}).get("task_name", "?")), }, ) def _entry(self) -> Any: # Thin wrapper kept for readability of callers in this class — # delegates to the shared helper so all flows use the same lookup. return _entry_for_issue(self.hass, self.data) def _patch_action_entity(self, entry: Any, new_entity_id: str) -> None: task_id = str((self.data or {}).get("task_id", "")) new_data = dict(entry.data) tasks = dict(new_data.get(CONF_TASKS, {})) task = dict(tasks.get(task_id) or {}) action = dict(task.get("on_complete_action") or {}) target = dict(action.get("target") or {}) target["entity_id"] = new_entity_id action["target"] = target task["on_complete_action"] = action tasks[task_id] = task new_data[CONF_TASKS] = tasks self.hass.config_entries.async_update_entry(entry, data=new_data) def _clear_action(self, entry: Any) -> None: task_id = str((self.data or {}).get("task_id", "")) new_data = dict(entry.data) tasks = dict(new_data.get(CONF_TASKS, {})) task = dict(tasks.get(task_id) or {}) task.pop("on_complete_action", None) tasks[task_id] = task new_data[CONF_TASKS] = tasks self.hass.config_entries.async_update_entry(entry, data=new_data) class DocumentStorageRepairFlow(RepairsFlow): """Confirm + reclaim orphaned / dangling document storage. Single action: run :meth:`DocumentStore.async_cleanup_issues`, which deletes unreferenced blob files and prunes dead document records. Completing the flow resolves the issue; if a later boot-time scan still finds anomalies it simply reappears. To leave things alone, use HA's built-in **Ignore** button. """ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult: """Confirm, then reclaim orphaned/dangling storage on submit.""" if user_input is not None: from . import DOCUMENT_STORE_KEY store = self.hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY) if store is not None: await store.async_cleanup_issues() return self.async_create_entry(data={}) return self.async_show_form(step_id="init", data_schema=vol.Schema({})) async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None, ) -> RepairsFlow: """Create a repair flow for the given issue.""" if issue_id.startswith("orphan_admin_panel_user_"): return OrphanAdminPanelUserRepairFlow() if issue_id.startswith("stale_action_entity_"): return StaleActionEntityRepairFlow() if issue_id == "document_storage_issues": return DocumentStorageRepairFlow() return MissingTriggerEntityRepairFlow()