Skip to content

Commit cadd51d

Browse files
committed
feat(backup): on-demand snapshot via scope=edits,action=create + UX polish
Three things from BAT validation feedback: 1. **On-demand snapshot for ``ha_manage_backup(scope='edits', action='create', domain=..., entity_id=...)``.** Mirrors the ``@with_auto_backup`` decorator's path — same handler registry, same throttle/rotation — but triggered explicitly when the user is about to manually edit something in the HA UI (outside the MCP-tool surface). Bypasses ``enable_auto_backup`` because the request is explicit; clears the per-entity throttle tracker so the capture always fires. Both ``domain`` and ``entity_id`` are required; unknown domain returns a structured 400 listing the registered domain set. The handler does ``object.__setattr__`` for the temporary toggle flip so the override is tight to this one call (Settings is a shared singleton). 2. **Strip leading ``automation.`` prefix from snapshot filenames.** When the caller passes ``identifier="automation.foo"`` (typical ``python_transform`` path with no config body), the snapshot filename was ``automation.automation.foo.<ts>.yaml`` — the domain segment duplicated. ``automation_backup_target`` now strips the leading ``automation.`` from the identifier on the fallback path; YAML body's ``entity_id`` keeps the full form for restore-side compatibility. Other domains (helper, label, etc.) already pass bare IDs and are unaffected. 3. **Bump default ``auto_backup_retain_per_entity`` from 20 to 100.** Real-world file sizes from BAT testing: medium automation 4 KB, complex AI-vision script 8 KB. 100 retention × 8 KB = 800 KB per entity — trivial. Old 20 default was conservative; 100 gives a week-plus of edit history at typical usage. Updated in: ``Settings`` field default, both addon config.yaml defaults, start.py default, both translations description text, and the ``test_apply_overrides_keeps_defaults_for_unset_envs`` unit assert. E2E coverage: - New ``TestEditsCreateOnDemandSnapshot`` class with a positive round-trip (create → on-demand snapshot → list → cleanup) and a negative (unknown domain → 400). - Existing ``TestManageBackupGating.test_edits_create_rejected`` renamed + rewritten to ``test_edits_create_requires_domain_and_entity_id`` — the combo is no longer rejected outright, but bare call without domain/entity_id still must fail with a structured validation error. Tool docstring + routing-matrix updated with the new ``(edits, create)`` row and a usage example for the "snapshot before manual UI edit" pattern.
1 parent e796e3d commit cadd51d

11 files changed

Lines changed: 217 additions & 13 deletions

File tree

homeassistant-addon-dev/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ options:
2929
enable_lite_docstrings: false
3030
enable_auto_backup: false
3131
auto_backup_throttle_minutes: 0
32-
auto_backup_retain_per_entity: 20
32+
auto_backup_retain_per_entity: 100
3333
tool_search_max_results: 5
3434
disabled_tools: ""
3535
pinned_tools: ""

homeassistant-addon-dev/translations/en.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ configuration:
6767
name: Auto-backup retention (per entity)
6868
description: >-
6969
Maximum number of snapshots kept per entity. Older snapshots beyond
70-
this cap are rotated out on each successful capture. Default 20,
70+
this cap are rotated out on each successful capture. Default 100,
7171
range 1–10000.
7272
enable_lite_docstrings:
7373
name: Enable lite tool docstrings (beta)

homeassistant-addon/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ options:
3434
enable_tool_search: false
3535
enable_auto_backup: false
3636
auto_backup_throttle_minutes: 0
37-
auto_backup_retain_per_entity: 20
37+
auto_backup_retain_per_entity: 100
3838
verify_ssl: true
3939
schema:
4040
backup_hint: list(strong|normal|weak|auto)

homeassistant-addon/start.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def main() -> int:
226226
enable_lite_docstrings = False # default
227227
enable_auto_backup = False # default (#1288)
228228
auto_backup_throttle_minutes = 0 # default — every write
229-
auto_backup_retain_per_entity = 20 # default
229+
auto_backup_retain_per_entity = 100 # default
230230
tool_search_max_results = 5 # default
231231
disabled_tools_raw = "" # default
232232
pinned_tools_raw = "" # default
@@ -277,9 +277,9 @@ def main() -> int:
277277
auto_backup_throttle_minutes = (
278278
raw_throttle if isinstance(raw_throttle, int) else 0
279279
)
280-
raw_retain = config.get("auto_backup_retain_per_entity", 20)
280+
raw_retain = config.get("auto_backup_retain_per_entity", 100)
281281
auto_backup_retain_per_entity = (
282-
raw_retain if isinstance(raw_retain, int) else 20
282+
raw_retain if isinstance(raw_retain, int) else 100
283283
)
284284
raw_max_results = config.get("tool_search_max_results", 5)
285285
tool_search_max_results = (

homeassistant-addon/translations/en.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ configuration:
4242
name: Auto-backup retention (per entity)
4343
description: >-
4444
Maximum number of snapshots kept per entity. Older snapshots beyond
45-
this cap are rotated out on each successful capture. Default 20,
45+
this cap are rotated out on each successful capture. Default 100,
4646
range 1–10000.
4747
verify_ssl:
4848
name: Verify TLS certificate

src/ha_mcp/backup_manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,9 @@ def throttle_seconds(self) -> int:
211211

212212
@property
213213
def retain_per_entity(self) -> int:
214-
return max(1, int(getattr(self._settings, "auto_backup_retain_per_entity", 20)))
214+
return max(
215+
1, int(getattr(self._settings, "auto_backup_retain_per_entity", 100))
216+
)
215217

216218
# ----- handler registration ------------------------------------------
217219

src/ha_mcp/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ class Settings(BaseSettings):
168168
# Max snapshots kept per entity. Older snapshots beyond this cap
169169
# are rotated out on each successful capture.
170170
auto_backup_retain_per_entity: int = Field(
171-
20, ge=1, le=10_000, alias="AUTO_BACKUP_RETAIN_PER_ENTITY"
171+
100, ge=1, le=10_000, alias="AUTO_BACKUP_RETAIN_PER_ENTITY"
172172
)
173173

174174
# Backup directory override. Empty ("") resolves at runtime to a

src/ha_mcp/tools/auto_backup.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,17 @@ def automation_backup_target(kw: dict[str, Any]) -> str:
7575
config_id = config.get("id")
7676
if config_id:
7777
return str(config_id)
78-
return _resolve_str(kw.get("identifier"))
78+
identifier = _resolve_str(kw.get("identifier"))
79+
# Strip the leading ``automation.`` prefix when the caller passed an
80+
# entity_id form (typical for ``python_transform`` calls that don't
81+
# carry a config body). Without this, snapshot files duplicate the
82+
# domain segment as ``automation.automation.<slug>.<ts>.yaml`` — the
83+
# body's entity_id keeps the prefix, only the filename / list key
84+
# tighten up. HA's automation upsert accepts either form for the
85+
# ``identifier`` param, so restore is unaffected.
86+
if identifier.startswith("automation."):
87+
identifier = identifier[len("automation.") :]
88+
return identifier
7989

8090

8191
def with_auto_backup(

src/ha_mcp/tools/backup.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ async def restore_backup(
544544
_VALID_COMBOS: set[tuple[str, str]] = {
545545
("snapshot", "create"),
546546
("snapshot", "restore"),
547+
("edits", "create"),
547548
("edits", "list"),
548549
("edits", "view"),
549550
("edits", "restore"),
@@ -607,6 +608,7 @@ def register_backup_tools(
607608
|---|---|---|
608609
| `snapshot` | `create` | Create a full HA tarball (config + addons, no DB by default). Heavy, seconds-long. |
609610
| `snapshot` | `restore` | Restore a full HA tarball. **Restarts HA.** Last-resort recovery. |
611+
| `edits` | `create` | On-demand snapshot of one entity (`domain` + `entity_id` required). Use before the user manually edits in the HA UI. Same handler path the decorator takes on writes; bypasses the `enable_auto_backup` toggle. |
610612
| `edits` | `list` | List per-entity auto-backups (lightweight). Filter by `domain` and/or `entity_id`. |
611613
| `edits` | `view` | Read one auto-backup file by name; returns YAML and parsed `config`. |
612614
| `edits` | `restore` | Re-apply one auto-backup. Creates a fresh safety snapshot first. **No HA restart.** |
@@ -624,6 +626,7 @@ def register_backup_tools(
624626
**Examples:**
625627
- Snapshot before risky op: `ha_manage_backup(scope="snapshot", action="create", name="Before_Big_Change")`
626628
- Restore full snapshot: `ha_manage_backup(scope="snapshot", action="restore", backup_id="dd7550ed")`
629+
- On-demand entity snapshot before a manual UI edit: `ha_manage_backup(scope="edits", action="create", domain="helper_input_boolean", entity_id="kitchen_lights_active")`
627630
- List recent auto-backups for one automation: `ha_manage_backup(scope="edits", action="list", domain="automation", entity_id="kitchen_lights")`
628631
- View an auto-backup: `ha_manage_backup(scope="edits", action="view", backup_name="automation.kitchen_lights.20260521_153000.yaml")`
629632
- Restore an auto-backup: `ha_manage_backup(scope="edits", action="restore", backup_name="automation.kitchen_lights.20260521_153000.yaml")`
@@ -729,6 +732,79 @@ async def ha_manage_backup(
729732
settings = get_global_settings()
730733
mgr = get_backup_manager(client, settings)
731734

735+
if action == "create":
736+
# On-demand snapshot for "I'm about to edit this in the HA UI,
737+
# save the current state first." Mirrors the path the
738+
# ``@with_auto_backup`` decorator takes on writes — same
739+
# handler registry, same throttle/rotation rules — but
740+
# triggered explicitly by the caller for entities they're
741+
# about to mutate outside the MCP-tool surface. Bypasses
742+
# ``enable_auto_backup`` because the request is explicit.
743+
dom = _require("domain", domain, scope, action)
744+
eid = _require("entity_id", entity_id, scope, action)
745+
if mgr._handlers.get(dom) is None:
746+
raise_tool_error(
747+
create_error_response(
748+
ErrorCode.VALIDATION_INVALID_PARAMETER,
749+
f"No backup handler registered for domain={dom!r}",
750+
context={"domain": dom, "entity_id": eid},
751+
suggestions=[
752+
"Supported domains: "
753+
+ ", ".join(sorted(mgr._handlers.keys())),
754+
],
755+
)
756+
)
757+
# ``maybe_snapshot`` returns None when ``enable_auto_backup``
758+
# is off OR throttle blocks. For an explicit on-demand call,
759+
# the off-state and the throttle-block are both wrong reasons
760+
# to skip — temporarily flip the toggle + clear the throttle
761+
# tracker entry so the capture always fires.
762+
was_enabled = mgr.enabled
763+
prev_ts = mgr._last_snapshot.pop(f"{dom}:{eid}", None)
764+
path = None
765+
try:
766+
# Force-enable for this call: read the live attr, override,
767+
# restore after. Settings object is shared with other
768+
# callers, so the override must be tight to this scope.
769+
if not was_enabled:
770+
object.__setattr__(settings, "enable_auto_backup", True)
771+
path = await mgr.maybe_snapshot(
772+
dom, eid, tool_name="ha_manage_backup.edits.create"
773+
)
774+
finally:
775+
if not was_enabled:
776+
object.__setattr__(settings, "enable_auto_backup", False)
777+
if path is None and prev_ts is not None:
778+
# Restore the throttle tracker only when the capture
779+
# didn't write (e.g. entity didn't exist at fetch
780+
# time). A successful capture sets a new timestamp
781+
# already.
782+
mgr._last_snapshot[f"{dom}:{eid}"] = prev_ts
783+
if path is None:
784+
raise_tool_error(
785+
create_error_response(
786+
ErrorCode.RESOURCE_NOT_FOUND,
787+
f"Could not snapshot {dom}:{eid} — entity not found "
788+
"or fetch returned no config",
789+
context={"domain": dom, "entity_id": eid},
790+
suggestions=[
791+
"Verify the entity exists via the matching "
792+
"ha_config_get_* tool first",
793+
"For helpers, pass domain='helper_<helper_type>' "
794+
"(e.g. 'helper_input_boolean')",
795+
],
796+
)
797+
)
798+
return {
799+
"success": True,
800+
"data": {
801+
"backup_name": path.name,
802+
"domain": dom,
803+
"entity_id": eid,
804+
"size": path.stat().st_size,
805+
},
806+
}
807+
732808
if action == "list":
733809
# list_snapshots does sync directory globbing + per-file stat;
734810
# offload to the executor so it doesn't block the event loop

tests/src/e2e/workflows/auto_backup/test_capture_and_restore.py

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,21 @@ async def test_rejects_invalid_combo(self, mcp_client) -> None:
131131
suggestions = error.get("suggestions", []) if isinstance(error, dict) else []
132132
assert any("Valid combinations" in s for s in suggestions)
133133

134-
async def test_edits_create_rejected(self, mcp_client) -> None:
135-
# (edits, create) is not valid — captures happen automatically via the decorator.
134+
async def test_edits_create_requires_domain_and_entity_id(self, mcp_client) -> None:
135+
# (edits, create) is the on-demand-snapshot combo — needs both
136+
# ``domain`` and ``entity_id`` to know what to capture. The
137+
# bare call must fail with a structured validation error rather
138+
# than silently routing through the decorator's auto-on-write
139+
# path (which only fires on actual writes).
136140
result = await safe_call_tool(
137141
mcp_client,
138142
"ha_manage_backup",
139143
{"scope": "edits", "action": "create"},
140144
)
141145
assert result.get("success") is False
146+
error = result.get("error", {})
147+
msg = error.get("message", "") if isinstance(error, dict) else ""
148+
assert "domain" in msg or "entity_id" in msg
142149

143150
async def test_snapshot_restore_requires_backup_id(self, mcp_client) -> None:
144151
# Validation should mention that backup_id is missing — not silently dispatch.
@@ -179,6 +186,115 @@ async def test_list_without_filter_returns_state(
179186
assert "retain_per_entity" in data
180187

181188

189+
# ---------------------------------------------------------------- on-demand snapshot
190+
191+
192+
@pytest.mark.automation
193+
@pytest.mark.cleanup
194+
@pytest.mark.external_only
195+
class TestEditsCreateOnDemandSnapshot:
196+
"""``ha_manage_backup(scope='edits', action='create', ...)`` — captures
197+
a snapshot of the named entity on demand. Use case: "I'm about to
198+
edit this in the HA UI; snapshot it first." Distinct from the
199+
auto-on-write path the ``@with_auto_backup`` decorator drives.
200+
201+
Exercises against an automation entity to share the existing
202+
automation create+edit fixture; the underlying maybe_snapshot path
203+
is domain-agnostic, so coverage of one domain is sufficient to
204+
pin the routing.
205+
"""
206+
207+
async def test_on_demand_snapshot_round_trip(
208+
self, mcp_client, monkeypatch: pytest.MonkeyPatch
209+
) -> None:
210+
_enable_auto_backup(monkeypatch)
211+
212+
suffix = uuid.uuid4().hex[:8]
213+
identifier = f"e2e_ondemand_{suffix}"
214+
# Create an automation to snapshot. We do NOT edit it — the
215+
# on-demand snapshot path must work without a write triggering
216+
# the decorator.
217+
original = {
218+
"alias": f"E2E On-Demand Original {suffix}",
219+
"trigger": [{"platform": "time", "at": "12:00:00"}],
220+
"action": [{"service": "homeassistant.no_op"}],
221+
}
222+
create = await safe_call_tool(
223+
mcp_client,
224+
"ha_config_set_automation",
225+
{"config": original, "identifier": identifier},
226+
)
227+
assert create.get("success") is not False
228+
229+
# On-demand snapshot.
230+
snap = await safe_call_tool(
231+
mcp_client,
232+
"ha_manage_backup",
233+
{
234+
"scope": "edits",
235+
"action": "create",
236+
"domain": "automation",
237+
"entity_id": identifier,
238+
},
239+
)
240+
assert snap.get("success") is True, f"on-demand snapshot failed: {snap}"
241+
data = snap.get("data", {})
242+
backup_name = data.get("backup_name")
243+
assert backup_name, f"backup_name missing from response: {snap}"
244+
assert data.get("domain") == "automation"
245+
assert data.get("entity_id") == identifier
246+
assert data.get("size", 0) > 0
247+
248+
# The snapshot must also show up in the list query.
249+
listing = await safe_call_tool(
250+
mcp_client,
251+
"ha_manage_backup",
252+
{
253+
"scope": "edits",
254+
"action": "list",
255+
"domain": "automation",
256+
"entity_id": identifier,
257+
},
258+
)
259+
assert listing.get("success") is True
260+
entries = listing.get("data", {}).get("backups", [])
261+
assert any(b["name"] == backup_name for b in entries), (
262+
f"on-demand snapshot {backup_name!r} not in list: "
263+
f"{[b['name'] for b in entries]}"
264+
)
265+
266+
# Cleanup: delete the snapshot + remove the automation.
267+
await safe_call_tool(
268+
mcp_client,
269+
"ha_manage_backup",
270+
{"scope": "edits", "action": "delete", "backup_name": backup_name},
271+
)
272+
await safe_call_tool(
273+
mcp_client,
274+
"ha_config_remove_automation",
275+
{"identifier": identifier},
276+
)
277+
278+
async def test_on_demand_snapshot_unknown_domain_rejected(
279+
self, mcp_client, monkeypatch: pytest.MonkeyPatch
280+
) -> None:
281+
_enable_auto_backup(monkeypatch)
282+
result = await safe_call_tool(
283+
mcp_client,
284+
"ha_manage_backup",
285+
{
286+
"scope": "edits",
287+
"action": "create",
288+
"domain": "not_a_real_domain",
289+
"entity_id": "anything",
290+
},
291+
)
292+
assert result.get("success") is False
293+
error = result.get("error", {})
294+
msg = error.get("message", "") if isinstance(error, dict) else ""
295+
assert "handler" in msg.lower() or "domain" in msg.lower()
296+
297+
182298
# ---------------------------------------------------------------- automation lane
183299

184300

0 commit comments

Comments
 (0)