|
| 1 | +# Auto-backup edited entities (closes #1288) |
| 2 | + |
| 3 | +**Status:** approved 2026-05-21 (user override on spec-review-subagent and writing-plans gates — direct directive to execute to draft PR) |
| 4 | +**Issue:** [#1288](https://github.com/homeassistant-ai/ha-mcp/issues/1288) |
| 5 | +**Branch:** `1288-auto-backup` (worktree `worktree/1288-auto-backup/`) |
| 6 | + |
| 7 | +## Goal |
| 8 | + |
| 9 | +Capture per-entity backups before every write/destructive MCP tool call, |
| 10 | +expose them for LLM-driven and UI-driven inspection, restore, and delete. |
| 11 | +Works in every ha-mcp deployment mode (addon, Docker, uvx) with no custom |
| 12 | +component dependency and no addon manifest privilege change. |
| 13 | + |
| 14 | +## Non-goals |
| 15 | + |
| 16 | +- Full HA snapshot rollback — `ha_backup_create` / `ha_backup_restore` already cover that |
| 17 | +- Cross-entity transactional restore (each restore is one entity) |
| 18 | +- Preserving user comments / whitespace from source YAML files (API-derived format) |
| 19 | + |
| 20 | +## Settings |
| 21 | + |
| 22 | +| Name | Env var | Default | Purpose | |
| 23 | +|---|---|---|---| |
| 24 | +| `enable_auto_backup` | `ENABLE_AUTO_BACKUP` | `False` | Master toggle | |
| 25 | +| `auto_backup_throttle_minutes` | `AUTO_BACKUP_THROTTLE_MINUTES` | `0` | Per-entity throttle window; 0 = no throttle, backup every write | |
| 26 | +| `auto_backup_retain_per_entity` | `AUTO_BACKUP_RETAIN_PER_ENTITY` | `20` | Max snapshots kept per entity; oldest rotated out | |
| 27 | +| `auto_backup_dir` | `HAMCP_BACKUP_DIR` | `""` (auto) | Override backup directory | |
| 28 | + |
| 29 | +Auto-default for `auto_backup_dir`: |
| 30 | +- Addon (`SUPERVISOR_TOKEN` set + `/data` exists): `/data/ha_mcp_backups/` |
| 31 | +- Else (Docker/uvx): `${XDG_DATA_HOME:-~/.local/share}/ha_mcp/backups/` |
| 32 | + |
| 33 | +## Architecture |
| 34 | + |
| 35 | +``` |
| 36 | +write/destructive tool → @with_auto_backup → BackupManager.maybe_snapshot |
| 37 | + │ |
| 38 | + ├── DomainHandler.fetch (HA REST/WS) |
| 39 | + └── write yaml, rotate, return path |
| 40 | +
|
| 41 | +ha_manage_auto_backup tool ─┐ |
| 42 | +/api/settings/backups ├──→ BackupManager (list/read/diff/restore/delete) |
| 43 | +Settings UI Backups tab ─┘ |
| 44 | +``` |
| 45 | + |
| 46 | +**`BackupManager`** (single instance per server, cached on the client object) owns: |
| 47 | +- Backup dir resolution and creation |
| 48 | +- Per-entity-key `asyncio.Lock` map to serialize fetch+write |
| 49 | +- Per-entity-key `last_snapshot_ts` for throttle |
| 50 | +- Domain handler registry: `{domain: DomainHandler}` |
| 51 | +- File operations (list, read, diff, write, delete, rotate) |
| 52 | + |
| 53 | +**`DomainHandler`** is a frozen dataclass per backup-domain: |
| 54 | +```python |
| 55 | +@dataclass(frozen=True) |
| 56 | +class DomainHandler: |
| 57 | + domain: str # "automation", "helper_input_boolean", "dashboard", ... |
| 58 | + fetch: Callable[[Client, str, dict], Awaitable[Any]] # (client, entity_id, tool_kwargs) -> current config |
| 59 | + restore: Callable[[Client, str, Any, dict], Awaitable[Any]] # (client, entity_id, config, restore_kwargs) -> result |
| 60 | +``` |
| 61 | + |
| 62 | +One handler per backed-up domain; helper types each get their own handler keyed `helper_<type>` so each helper type lists/restores independently. |
| 63 | + |
| 64 | +## Write flow (the decorator) |
| 65 | + |
| 66 | +```python |
| 67 | +@with_auto_backup(domain="automation", id_param="identifier") |
| 68 | +@mcp.tool(...) |
| 69 | +@log_tool_usage |
| 70 | +async def ha_config_set_automation(self, identifier: str, ...): |
| 71 | + ... |
| 72 | +``` |
| 73 | + |
| 74 | +`id_param` names the kwarg that carries the entity ID. For helpers, the |
| 75 | +decorator takes `domain_fn` instead — a callable that computes the domain |
| 76 | +key from kwargs (so `helper_type="timer"` → domain `helper_timer`). |
| 77 | + |
| 78 | +Decorator behavior (best-effort, never raises): |
| 79 | +1. Look up manager off `self._client._auto_backup_manager` (lazy-built on first use). |
| 80 | +2. If `enable_auto_backup` is False → call wrapped function and return. |
| 81 | +3. Resolve `entity_key = f"{domain}:{entity_id}"`. If entity ID is `None`/empty (a *create* call with no ID) → skip backup, call wrapped function. |
| 82 | +4. Inside per-key lock: check throttle; if elapsed, fetch + write + rotate. |
| 83 | +5. All exceptions from steps 3-4 are logged as WARNING and swallowed. |
| 84 | +6. Call wrapped function with original args. |
| 85 | + |
| 86 | +**Why this shape:** the original tool body is fully decoupled from backup; |
| 87 | +removing the decorator should not change tool behavior. Backup is never |
| 88 | +on the critical path of the write. |
| 89 | + |
| 90 | +## File format |
| 91 | + |
| 92 | +```yaml |
| 93 | +# ha_mcp_backup |
| 94 | +schema_version: 1 |
| 95 | +domain: automation |
| 96 | +entity_id: kitchen_lights |
| 97 | +captured: 2026-05-21T15:30:00+00:00 |
| 98 | +tool: ha_config_set_automation |
| 99 | +config: |
| 100 | + alias: Kitchen lights |
| 101 | + trigger: ... |
| 102 | + action: ... |
| 103 | +``` |
| 104 | +
|
| 105 | +Filename: `<domain>.<safe_entity_id>.<YYYYMMDD_HHMMSS>.yaml`. `safe_entity_id` |
| 106 | +replaces `os.sep` and any non-`[A-Za-z0-9._-]` character with `_`. Backup |
| 107 | +dir is flat (no per-domain subdirs) so glob retention is simple. |
| 108 | + |
| 109 | +## Restore flow |
| 110 | + |
| 111 | +User-facing entry points (both call `BackupManager.restore`): |
| 112 | +- LLM: `ha_manage_auto_backup(action="restore", name=...)` |
| 113 | +- UI: `POST /api/settings/backups/<name>/restore` |
| 114 | + |
| 115 | +`BackupManager.restore(name)`: |
| 116 | +1. Load file, parse YAML, validate schema (version, required keys). |
| 117 | +2. Resolve `DomainHandler` from `domain` field; if missing → return RESTORE_FAILED with suggestions. |
| 118 | +3. Take a fresh *safety backup* of the entity's CURRENT state via the same path as the decorator (`maybe_snapshot` with throttle disabled). The safety backup is recorded so the user can undo the restore. |
| 119 | +4. Call `handler.restore(client, entity_id, config)` (which under the hood calls the same HA REST/WS endpoint the original `set_*` tool uses). |
| 120 | +5. Return `{success, entity_key, safety_backup, restored_from}`. |
| 121 | + |
| 122 | +Restore is upsert: re-creates entities that were deleted between backup and restore. If HA rejects (validation error, etc.), error propagates with structured suggestions. |
| 123 | + |
| 124 | +## List / view / diff / delete flow |
| 125 | + |
| 126 | +Each operation is a thin method on `BackupManager`, exposed via: |
| 127 | + |
| 128 | +| MCP tool | UI endpoint | UI element | |
| 129 | +|---|---|---| |
| 130 | +| `ha_manage_auto_backup(action="list", domain?, entity_id?, since?, limit?)` | `GET /api/settings/backups?...` | Backup list table with filters | |
| 131 | +| `ha_manage_auto_backup(action="view", name=...)` | `GET /api/settings/backups/<name>` | "View" button → modal showing YAML | |
| 132 | +| `ha_manage_auto_backup(action="diff", name=...)` | `GET /api/settings/backups/<name>/diff` | "Diff" button → modal with unified diff vs current state | |
| 133 | +| `ha_manage_auto_backup(action="restore", name=...)` | `POST /api/settings/backups/<name>/restore` | "Restore" button (confirmation modal) | |
| 134 | +| `ha_manage_auto_backup(action="delete", name=...)` *or* `(action="delete_bulk", domain?, entity_id?, older_than?)` | `DELETE /api/settings/backups/<name>`, `DELETE /api/settings/backups?...` | Per-row "Delete" + bulk "Delete all matching filters" | |
| 135 | + |
| 136 | +**List item shape:** |
| 137 | +```json |
| 138 | +{ |
| 139 | + "name": "automation.kitchen_lights.20260521_153000.yaml", |
| 140 | + "domain": "automation", |
| 141 | + "entity_id": "kitchen_lights", |
| 142 | + "captured": "2026-05-21T15:30:00+00:00", |
| 143 | + "tool": "ha_config_set_automation", |
| 144 | + "size": 412 |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +**Diff:** `difflib.unified_diff` against the current entity's config (fetched via the same handler.fetch as backup), yielding text diff. |
| 149 | + |
| 150 | +## Settings UI changes |
| 151 | + |
| 152 | +`settings_ui.py` gains: |
| 153 | +- 5 new routes (list / view / diff / restore / delete + bulk delete) |
| 154 | +- A "Backups" tab in the existing `/settings` HTML page — list table with filter inputs (domain, entity, since), each row with View / Diff / Restore / Delete buttons, plus a "Bulk delete matching" action |
| 155 | +- A small JS section at the bottom of the existing inline script tag |
| 156 | + |
| 157 | +## Tool surface (28 wrapped) |
| 158 | + |
| 159 | +`ha_config_set_automation`, `ha_config_remove_automation`, |
| 160 | +`ha_config_set_script`, `ha_config_remove_script`, |
| 161 | +`ha_config_set_scene`, `ha_config_remove_scene`, |
| 162 | +`ha_config_set_helper` (one DomainHandler per helper_type, dispatched via `domain_fn`), |
| 163 | +`ha_config_set_dashboard`, `ha_config_delete_dashboard`, |
| 164 | +`ha_config_set_dashboard_resource`, `ha_config_delete_dashboard_resource`, |
| 165 | +`ha_config_set_label`, `ha_config_remove_label`, |
| 166 | +`ha_config_set_category`, `ha_config_remove_category`, |
| 167 | +`ha_config_set_group`, `ha_config_remove_group`, |
| 168 | +`ha_config_set_calendar_event`, `ha_config_remove_calendar_event`, |
| 169 | +`ha_set_zone`, `ha_remove_zone`, |
| 170 | +`ha_set_area_or_floor`, `ha_remove_area_or_floor`, |
| 171 | +`ha_set_todo_item`, `ha_remove_todo_item`, |
| 172 | +`ha_set_entity`, |
| 173 | +`ha_set_integration_enabled`, `ha_delete_helpers_integrations`. |
| 174 | + |
| 175 | +**Explicitly NOT wrapped:** `ha_call_service`, `ha_call_event`, `ha_restart`, `ha_reload_core`, `ha_check_config`, `ha_eval_template`, `ha_delete_file`, `ha_remove_entity`, `ha_remove_device`, `ha_update_device`, `ha_install_mcp_tools`, `ha_hacs_*`, blueprint/import ops. |
| 176 | + |
| 177 | +## Error handling |
| 178 | + |
| 179 | +- Backup write errors: WARNING log, original write proceeds. |
| 180 | +- Restore errors: structured `RESTORE_FAILED` error response with suggestions. |
| 181 | +- Schema-version mismatch on restore: structured `BACKUP_INCOMPATIBLE` error. |
| 182 | +- Filesystem init errors (backup dir uncreatable at startup): single startup WARNING, manager runs with snapshots silently skipped until restart. |
| 183 | +- Concurrent writes to same entity: per-entity `asyncio.Lock` serializes; no duplicate snapshots within one window. |
| 184 | + |
| 185 | +## Testing strategy |
| 186 | + |
| 187 | +**Unit (`tests/src/unit/test_backup_manager.py`):** |
| 188 | +- Throttle math (boundary at `auto_backup_throttle_minutes=0` always snapshots; >0 enforces window) |
| 189 | +- Retention rotation (`auto_backup_retain_per_entity` enforced; oldest deleted) |
| 190 | +- Filename safety (path separators, special chars, unicode) |
| 191 | +- Diff output format |
| 192 | +- Schema version validation on restore |
| 193 | +- Best-effort error handling (mocked fetch raises → wrapped tool still runs) |
| 194 | + |
| 195 | +**E2E (`tests/src/e2e/workflows/auto_backup/`):** |
| 196 | +One test file per backed-up domain (`test_automation.py`, `test_script.py`, ..., one per `DomainHandler`). Each test: |
| 197 | +1. Toggle on |
| 198 | +2. Create/exist entity |
| 199 | +3. Edit via the wrapped tool → backup file appears |
| 200 | +4. Edit again → another backup file appears (with throttle off) OR no new file (with throttle on, within window) |
| 201 | +5. Restore first backup via `ha_manage_auto_backup(action="restore", ...)` — entity returns to original state |
| 202 | +6. Verify a safety backup was created |
| 203 | +7. Delete via `ha_manage_auto_backup(action="delete", ...)` — file removed |
| 204 | +8. Toggle off → next edit produces no backup |
| 205 | + |
| 206 | +## Files |
| 207 | + |
| 208 | +**New:** |
| 209 | +- `src/ha_mcp/backup_manager.py` |
| 210 | +- `src/ha_mcp/tools/auto_backup.py` (decorator) |
| 211 | +- `src/ha_mcp/tools/tools_auto_backup.py` (`ha_manage_auto_backup` tool) |
| 212 | +- `tests/src/unit/test_backup_manager.py` |
| 213 | +- `tests/src/e2e/workflows/auto_backup/` (one test file per domain) |
| 214 | + |
| 215 | +**Modified:** |
| 216 | +- `src/ha_mcp/config.py` (4 settings) |
| 217 | +- `src/ha_mcp/settings_ui.py` (5 routes + Backups tab) |
| 218 | +- `homeassistant-addon/config.yaml` + `homeassistant-addon-dev/config.yaml` (4 options + schema) |
| 219 | +- `homeassistant-addon-dev/translations/en.yaml` (4 translations) |
| 220 | +- 28 `tools_*.py` modules (one `@with_auto_backup(...)` line each above existing `@mcp.tool(...)`) |
0 commit comments