708 lines
27 KiB
Python
708 lines
27 KiB
Python
"""Guarded YAML access for Home Assistant configuration files.
|
|
|
|
The language model is untrusted. Reads are bounded and redact likely inline
|
|
credentials. Every mutation is previewed first, bound to the exact current file
|
|
hash, backed up, written atomically, checked by Home Assistant and rolled back
|
|
when validation or reload fails. ``secrets.yaml`` is never addressable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import difflib
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.util import slugify
|
|
|
|
from ..identity import user_context
|
|
from ..protocol import ToolError, internal_error
|
|
from ..registry import LIMIT_FIELD, OFFSET_FIELD, paginate, schema, tool
|
|
|
|
# kind -> (filename, parsed structure, reload service domain)
|
|
_KINDS: dict[str, tuple[str, str, str | None]] = {
|
|
"automation": ("automations.yaml", "list", "automation"),
|
|
"script": ("scripts.yaml", "dict", "script"),
|
|
"scene": ("scenes.yaml", "list", "scene"),
|
|
# configuration.yaml is intentionally raw-read/replace only. Treating its
|
|
# top-level keys as CRUD records would be dangerously misleading.
|
|
"configuration": ("configuration.yaml", "dict", None),
|
|
}
|
|
_STRUCTURED_KINDS = frozenset({"automation", "script", "scene"})
|
|
_OPS = (
|
|
"list",
|
|
"get",
|
|
"read_source",
|
|
"find_source",
|
|
"find_commented_blocks",
|
|
"list_backups",
|
|
"create",
|
|
"update",
|
|
"delete",
|
|
"replace_source_text",
|
|
"restore_backup",
|
|
"reload",
|
|
)
|
|
_MUTATING_OPS = frozenset({"create", "update", "delete", "replace_source_text", "restore_backup"})
|
|
_TICKET_TTL_SECONDS = 600
|
|
_MAX_SOURCE_BYTES = 2 * 1024 * 1024
|
|
_MAX_REPLACEMENT_CHARS = 50_000
|
|
_PENDING: dict[str, dict[str, Any]] = {}
|
|
_SENSITIVE_LINE = re.compile(
|
|
r"(?i)^(?P<prefix>\s*[^#\n]*(?:password|passwd|token|secret|api[_-]?key|authorization)[^:]*:\s*).*$"
|
|
)
|
|
_SECRET_REFERENCE = re.compile(r"!secret\s+[^\s#]+", re.IGNORECASE)
|
|
|
|
|
|
@tool(
|
|
name="ha_yaml_config",
|
|
description=(
|
|
"Safely inspect and edit Home Assistant YAML. Structured CRUD is limited to "
|
|
"automations.yaml, scripts.yaml and scenes.yaml. Raw source operations also "
|
|
"allow configuration.yaml so commented-out blocks can be found and reviewed. "
|
|
"secrets.yaml and arbitrary paths are impossible. Use find_commented_blocks once "
|
|
"to inventory fully commented YAML entries; use read_source/find_source only for "
|
|
"other comments or exact YAML text. Every mutation first returns a diff/preview "
|
|
"and one-time approval_ticket; only repeat the exact unchanged call with "
|
|
"confirm=true after explicit user approval. Writes create a backup, are atomic, "
|
|
"run Home Assistant config validation, roll back on failure, and reload the "
|
|
"affected domain when supported. Never claim a preview changed Home Assistant."
|
|
),
|
|
input_schema=schema(
|
|
properties={
|
|
"kind": {"type": "string", "enum": list(_KINDS)},
|
|
"op": {"type": "string", "enum": list(_OPS)},
|
|
"id": {
|
|
"type": "string",
|
|
"description": "Entry id for structured get/create/update/delete.",
|
|
},
|
|
"config": {
|
|
"type": "object",
|
|
"additionalProperties": True,
|
|
"description": "Complete entry config for structured create/update.",
|
|
},
|
|
"query": {
|
|
"type": "string",
|
|
"maxLength": 500,
|
|
"description": "Case-insensitive literal text for find_source.",
|
|
},
|
|
"start_line": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"default": 1,
|
|
"description": "First 1-based line returned by read_source.",
|
|
},
|
|
"max_lines": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": 400,
|
|
"default": 120,
|
|
"description": "Bounded source lines returned by read_source/find_source.",
|
|
},
|
|
"old_text": {
|
|
"type": "string",
|
|
"minLength": 1,
|
|
"maxLength": _MAX_REPLACEMENT_CHARS,
|
|
"description": "Exact unique YAML source text to replace.",
|
|
},
|
|
"new_text": {
|
|
"type": "string",
|
|
"maxLength": _MAX_REPLACEMENT_CHARS,
|
|
"description": "Replacement YAML source text; may be empty to remove a block.",
|
|
},
|
|
"backup_id": {
|
|
"type": "string",
|
|
"pattern": r"^[a-z0-9_.-]+$",
|
|
"description": "Opaque filename returned by list_backups.",
|
|
},
|
|
"confirm": {
|
|
"type": "boolean",
|
|
"default": False,
|
|
"description": "True only after the user approved the exact preview.",
|
|
},
|
|
"approval_ticket": {
|
|
"type": "string",
|
|
"description": "One-time ticket from the unchanged mutation preview.",
|
|
},
|
|
"limit": LIMIT_FIELD,
|
|
"offset": OFFSET_FIELD,
|
|
},
|
|
required=["kind", "op"],
|
|
),
|
|
read_only=False,
|
|
idempotent=False,
|
|
requires_admin=True,
|
|
write_ops=["create", "update", "replace_source_text", "reload"],
|
|
destructive_ops=["delete", "restore_backup"],
|
|
admin_ops=["list", "get", "read_source", "find_source", "find_commented_blocks", "list_backups"],
|
|
)
|
|
async def ha_yaml_config(
|
|
hass: HomeAssistant,
|
|
kind: str,
|
|
op: str,
|
|
id: str | None = None,
|
|
config: dict[str, Any] | None = None,
|
|
query: str | None = None,
|
|
start_line: int = 1,
|
|
max_lines: int = 120,
|
|
old_text: str | None = None,
|
|
new_text: str | None = None,
|
|
backup_id: str | None = None,
|
|
confirm: bool = False,
|
|
approval_ticket: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
) -> dict[str, Any]:
|
|
if kind not in _KINDS:
|
|
raise ToolError(f"unknown kind '{kind}'")
|
|
if op not in _OPS:
|
|
raise ToolError(f"unknown op '{op}'")
|
|
|
|
filename, structure, reload_domain = _KINDS[kind]
|
|
path = Path(hass.config.path(filename))
|
|
|
|
if op in {"list", "get", "create", "update", "delete", "reload"} and kind not in _STRUCTURED_KINDS:
|
|
raise ToolError(
|
|
"configuration.yaml supports only read_source, find_source, list_backups, "
|
|
"replace_source_text and restore_backup"
|
|
)
|
|
|
|
if op == "read_source":
|
|
return await _read_source(hass, path, start_line, max_lines)
|
|
if op == "find_source":
|
|
if not query:
|
|
raise ToolError("op=find_source requires query")
|
|
return await _find_source(hass, path, query, max_lines)
|
|
if op == "find_commented_blocks":
|
|
if kind not in {"automation", "scene"}:
|
|
raise ToolError("find_commented_blocks supports automation and scene list files")
|
|
return await _find_commented_blocks(hass, path, max_lines)
|
|
if op == "list_backups":
|
|
return await _list_backups(hass, filename, limit, offset)
|
|
if op == "replace_source_text":
|
|
if old_text is None or new_text is None:
|
|
raise ToolError("op=replace_source_text requires old_text and new_text")
|
|
_reject_sensitive_replacement(old_text, new_text)
|
|
current = await _read_text(hass, path)
|
|
if current.count(old_text) != 1:
|
|
raise ToolError(
|
|
f"old_text must occur exactly once in {filename}; found {current.count(old_text)} occurrences"
|
|
)
|
|
proposed = current.replace(old_text, new_text, 1)
|
|
await _validate_yaml_text(hass, proposed, structure, filename)
|
|
change = _change_record(kind, op, current, {"old_text": old_text, "new_text": new_text})
|
|
if not confirm:
|
|
return _preview(change, _source_diff(filename, current, proposed))
|
|
_consume_ticket(change, approval_ticket)
|
|
return await _commit_text(hass, path, filename, structure, reload_domain, current, proposed)
|
|
if op == "restore_backup":
|
|
if not backup_id:
|
|
raise ToolError("op=restore_backup requires backup_id from list_backups")
|
|
current = await _read_text(hass, path)
|
|
restored = await _read_backup(hass, filename, backup_id)
|
|
await _validate_yaml_text(hass, restored, structure, filename)
|
|
change = _change_record(kind, op, current, {"backup_id": backup_id})
|
|
if not confirm:
|
|
return _preview(change, _source_diff(filename, current, restored), extra={"backup_id": backup_id})
|
|
_consume_ticket(change, approval_ticket)
|
|
return await _commit_text(hass, path, filename, structure, reload_domain, current, restored)
|
|
|
|
data = await _load(hass, path, structure)
|
|
if op == "list":
|
|
return paginate(_to_list(data, structure), limit, offset)
|
|
if op == "get":
|
|
if not id:
|
|
raise ToolError("op=get requires id")
|
|
item = _find(data, structure, id)
|
|
if item is None:
|
|
raise ToolError(f"{kind} '{id}' not found in {filename}")
|
|
return item
|
|
if op == "reload":
|
|
await _reload(hass, reload_domain)
|
|
return {"reloaded": reload_domain, "changed_file": False}
|
|
|
|
current_text = await _read_text(hass, path)
|
|
proposed_data = _copy_data(data)
|
|
result: dict[str, Any]
|
|
if op == "create":
|
|
if not config:
|
|
raise ToolError("op=create requires config")
|
|
# The generated id must be deterministic so the exact preview can be
|
|
# confirmed in a second call without silently proposing another entry.
|
|
generated_id = f"mcp_{hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()[:16]}"
|
|
new_id = id or config.get("id") or generated_id
|
|
if _find(proposed_data, structure, new_id) is not None:
|
|
raise ToolError(f"{kind} '{new_id}' already exists")
|
|
if structure == "list":
|
|
proposed_data.append({"id": new_id, **{k: v for k, v in config.items() if k != "id"}})
|
|
else:
|
|
proposed_data[new_id] = config
|
|
result = {
|
|
"operation": "create",
|
|
"id": new_id,
|
|
"proposed_entry": _find(proposed_data, structure, new_id),
|
|
}
|
|
elif op == "update":
|
|
if not id or not config:
|
|
raise ToolError("op=update requires id and config")
|
|
before = _find(proposed_data, structure, id)
|
|
if before is None or not _replace(proposed_data, structure, id, config):
|
|
raise ToolError(f"{kind} '{id}' not found")
|
|
result = {"operation": "update", "id": id, "current_entry": before, "proposed_entry": _find(proposed_data, structure, id)}
|
|
elif op == "delete":
|
|
if not id:
|
|
raise ToolError("op=delete requires id")
|
|
before = _find(proposed_data, structure, id)
|
|
if before is None or not _remove(proposed_data, structure, id):
|
|
raise ToolError(f"{kind} '{id}' not found")
|
|
result = {"operation": "delete", "id": id, "current_entry": before}
|
|
else:
|
|
raise ToolError(f"unsupported op '{op}'")
|
|
|
|
change = _change_record(kind, op, current_text, {"id": id, "config": config, "result": result})
|
|
if not confirm:
|
|
return _preview(change, extra=result)
|
|
_consume_ticket(change, approval_ticket)
|
|
committed = await _commit_data(hass, path, filename, structure, reload_domain, current_text, proposed_data)
|
|
return {**result, **committed}
|
|
|
|
|
|
def _copy_data(data: Any) -> Any:
|
|
return copy.deepcopy(data)
|
|
|
|
|
|
def _fingerprint(text: str) -> str:
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _change_record(kind: str, op: str, current: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"kind": kind,
|
|
"op": op,
|
|
"current_sha256": _fingerprint(current),
|
|
"arguments": arguments,
|
|
}
|
|
|
|
|
|
def _new_ticket(change: dict[str, Any]) -> str:
|
|
now = time.time()
|
|
for key, value in list(_PENDING.items()):
|
|
if value["expires_at"] <= now:
|
|
_PENDING.pop(key, None)
|
|
ticket = secrets.token_urlsafe(18)
|
|
_PENDING[ticket] = {
|
|
"fingerprint": _fingerprint(json.dumps(change, sort_keys=True, separators=(",", ":"))),
|
|
"expires_at": now + _TICKET_TTL_SECONDS,
|
|
}
|
|
return ticket
|
|
|
|
|
|
def _consume_ticket(change: dict[str, Any], ticket: str | None) -> None:
|
|
record = _PENDING.pop(ticket, None) if ticket else None
|
|
expected = _fingerprint(json.dumps(change, sort_keys=True, separators=(",", ":")))
|
|
if not record or record["expires_at"] <= time.time() or record["fingerprint"] != expected:
|
|
raise ToolError(
|
|
"approval_ticket is missing, expired, already used, or does not match the exact "
|
|
"change/current file. Run the same operation without confirm, show the preview, "
|
|
"then repeat unchanged with confirm=true only after explicit user approval."
|
|
)
|
|
|
|
|
|
def _preview(change: dict[str, Any], diff: list[str] | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
return {
|
|
"changed": False,
|
|
"confirmation_required": True,
|
|
"approval_ticket": _new_ticket(change),
|
|
"ticket_expires_in_seconds": _TICKET_TTL_SECONDS,
|
|
"current_sha256": change["current_sha256"],
|
|
**(extra or {}),
|
|
**({"diff": diff, "diff_truncated": len(diff) >= 120} if diff is not None else {}),
|
|
"model_instruction": (
|
|
"This is a preview only. Show it to the user and stop. Do not claim anything was "
|
|
"changed. After explicit approval repeat the exact call with confirm=true and approval_ticket."
|
|
),
|
|
}
|
|
|
|
|
|
def _redact_line(line: str) -> str:
|
|
match = _SENSITIVE_LINE.match(line)
|
|
if match:
|
|
return f"{match.group('prefix')}<redacted>"
|
|
return _SECRET_REFERENCE.sub("!secret <redacted-reference>", line)
|
|
|
|
|
|
def _reject_sensitive_replacement(*values: str) -> None:
|
|
for value in values:
|
|
if any(_SENSITIVE_LINE.match(line) for line in value.splitlines()) or _SECRET_REFERENCE.search(value):
|
|
raise ToolError(
|
|
"Raw replacement containing credential-like keys or !secret references is refused. "
|
|
"Edit that material locally outside the LLM context."
|
|
)
|
|
|
|
|
|
async def _read_text(hass: HomeAssistant, path: Path) -> str:
|
|
def _read() -> str:
|
|
if not path.exists():
|
|
return ""
|
|
if path.stat().st_size > _MAX_SOURCE_BYTES:
|
|
raise ToolError(f"{path.name} exceeds the {_MAX_SOURCE_BYTES} byte safety limit")
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
return await hass.async_add_executor_job(_read)
|
|
|
|
|
|
async def _read_source(hass: HomeAssistant, path: Path, start_line: int, max_lines: int) -> dict[str, Any]:
|
|
text = await _read_text(hass, path)
|
|
lines = text.splitlines()
|
|
start = max(1, start_line)
|
|
count = max(1, min(max_lines, 400))
|
|
selected = lines[start - 1 : start - 1 + count]
|
|
return {
|
|
"file": path.name,
|
|
"sha256": _fingerprint(text),
|
|
"total_lines": len(lines),
|
|
"start_line": start,
|
|
"returned_lines": len(selected),
|
|
"has_more": start - 1 + len(selected) < len(lines),
|
|
"lines": [{"line": start + index, "text": _redact_line(line)} for index, line in enumerate(selected)],
|
|
"redaction_note": "Credential-like values and !secret reference names are redacted.",
|
|
}
|
|
|
|
|
|
async def _find_source(hass: HomeAssistant, path: Path, query: str, max_lines: int) -> dict[str, Any]:
|
|
text = await _read_text(hass, path)
|
|
lines = text.splitlines()
|
|
hits = [index for index, line in enumerate(lines) if query.casefold() in line.casefold()]
|
|
cap = max(1, min(max_lines, 400))
|
|
selected = hits[:cap]
|
|
return {
|
|
"file": path.name,
|
|
"sha256": _fingerprint(text),
|
|
"authoritative_match_count": len(hits),
|
|
"returned_count": len(selected),
|
|
"has_more": len(hits) > len(selected),
|
|
"matches": [{"line": index + 1, "text": _redact_line(lines[index])} for index in selected],
|
|
"redaction_note": "Credential-like values and !secret reference names are redacted.",
|
|
}
|
|
|
|
|
|
def _extract_commented_blocks(text: str, max_lines: int) -> tuple[list[dict[str, Any]], bool]:
|
|
"""Return top-level YAML list entries whose every source line is commented.
|
|
|
|
This intentionally recognizes only the conservative ``# - id:`` form used
|
|
by Home Assistant's automations/scenes editor. Ordinary prose comments,
|
|
partially disabled entries and nested comments are not treated as entries.
|
|
"""
|
|
lines = text.splitlines()
|
|
start_pattern = re.compile(r"^\s*#\s*-\s+id\s*:\s*(.*?)\s*$", re.IGNORECASE)
|
|
alias_pattern = re.compile(r"^\s*#\s+alias\s*:\s*(.*?)\s*$", re.IGNORECASE)
|
|
blocks: list[dict[str, Any]] = []
|
|
consumed = 0
|
|
index = 0
|
|
truncated = False
|
|
|
|
def clean_scalar(value: str) -> str:
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
return value[1:-1]
|
|
return value
|
|
|
|
while index < len(lines):
|
|
match = start_pattern.match(lines[index])
|
|
if not match:
|
|
index += 1
|
|
continue
|
|
start = index
|
|
block_lines = [lines[index]]
|
|
index += 1
|
|
while index < len(lines):
|
|
if start_pattern.match(lines[index]):
|
|
break
|
|
if not lines[index].strip() or not re.match(r"^\s*#", lines[index]):
|
|
break
|
|
block_lines.append(lines[index])
|
|
index += 1
|
|
if consumed + len(block_lines) > max_lines:
|
|
truncated = True
|
|
break
|
|
alias = None
|
|
for line in block_lines:
|
|
alias_match = alias_pattern.match(line)
|
|
if alias_match:
|
|
alias = clean_scalar(alias_match.group(1))
|
|
break
|
|
blocks.append(
|
|
{
|
|
"start_line": start + 1,
|
|
"end_line": start + len(block_lines),
|
|
"id": clean_scalar(match.group(1)),
|
|
"alias": alias,
|
|
"source": [
|
|
{"line": start + offset + 1, "text": _redact_line(line)}
|
|
for offset, line in enumerate(block_lines)
|
|
],
|
|
}
|
|
)
|
|
consumed += len(block_lines)
|
|
return blocks, truncated
|
|
|
|
|
|
async def _find_commented_blocks(
|
|
hass: HomeAssistant, path: Path, max_lines: int
|
|
) -> dict[str, Any]:
|
|
text = await _read_text(hass, path)
|
|
cap = max(1, min(max_lines, 400))
|
|
blocks, truncated = _extract_commented_blocks(text, cap)
|
|
return {
|
|
"file": path.name,
|
|
"sha256": _fingerprint(text),
|
|
"authoritative_block_count": len(blocks) if not truncated else None,
|
|
"returned_block_count": len(blocks),
|
|
"returned_source_lines": sum(len(block["source"]) for block in blocks),
|
|
"has_more": truncated,
|
|
"blocks": blocks,
|
|
"recognition_rule": "Only fully commented top-level '# - id:' YAML list entries are returned.",
|
|
"redaction_note": "Credential-like values and !secret reference names are redacted.",
|
|
}
|
|
|
|
|
|
def _source_diff(filename: str, before: str, after: str) -> list[str]:
|
|
return list(
|
|
difflib.unified_diff(
|
|
before.splitlines(),
|
|
after.splitlines(),
|
|
fromfile=f"{filename}:before",
|
|
tofile=f"{filename}:after",
|
|
lineterm="",
|
|
n=3,
|
|
)
|
|
)[:120]
|
|
|
|
|
|
def _backup_dir(hass: HomeAssistant) -> Path:
|
|
return Path(hass.config.path(".hass_mcp_backups", "yaml"))
|
|
|
|
|
|
async def _create_backup(hass: HomeAssistant, filename: str, content: str) -> str:
|
|
backup_id = f"{filename}.{time.strftime('%Y%m%d-%H%M%S')}.{_fingerprint(content)[:10]}.bak"
|
|
directory = _backup_dir(hass)
|
|
|
|
def _write() -> None:
|
|
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
target = directory / backup_id
|
|
target.write_text(content, encoding="utf-8")
|
|
target.chmod(0o600)
|
|
|
|
await hass.async_add_executor_job(_write)
|
|
return backup_id
|
|
|
|
|
|
async def _list_backups(hass: HomeAssistant, filename: str, limit: int, offset: int) -> dict[str, Any]:
|
|
directory = _backup_dir(hass)
|
|
|
|
def _list() -> list[dict[str, Any]]:
|
|
if not directory.exists():
|
|
return []
|
|
rows = []
|
|
for path in directory.glob(f"{filename}.*.bak"):
|
|
stat = path.stat()
|
|
rows.append({"backup_id": path.name, "size": stat.st_size, "created_unix": int(stat.st_mtime)})
|
|
return sorted(rows, key=lambda row: row["created_unix"], reverse=True)
|
|
|
|
return paginate(await hass.async_add_executor_job(_list), limit, offset)
|
|
|
|
|
|
async def _read_backup(hass: HomeAssistant, filename: str, backup_id: str) -> str:
|
|
if Path(backup_id).name != backup_id or not backup_id.startswith(f"{filename}.") or not backup_id.endswith(".bak"):
|
|
raise ToolError("backup_id is not valid for this YAML kind")
|
|
path = _backup_dir(hass) / backup_id
|
|
|
|
def _read() -> str:
|
|
if not path.is_file():
|
|
raise ToolError("backup_id not found")
|
|
if path.stat().st_size > _MAX_SOURCE_BYTES:
|
|
raise ToolError("backup exceeds safety limit")
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
return await hass.async_add_executor_job(_read)
|
|
|
|
|
|
async def _validate_yaml_text(hass: HomeAssistant, content: str, structure: str, filename: str) -> Any:
|
|
from homeassistant.util.yaml import parse_yaml
|
|
|
|
def _parse() -> Any:
|
|
parsed = parse_yaml(content) if content.strip() else ([] if structure == "list" else {})
|
|
if structure == "list" and not isinstance(parsed, list):
|
|
raise ToolError(f"{filename} must be a YAML list, got {type(parsed).__name__}")
|
|
if structure == "dict" and not isinstance(parsed, dict):
|
|
raise ToolError(f"{filename} must be a YAML mapping, got {type(parsed).__name__}")
|
|
return parsed
|
|
|
|
return await hass.async_add_executor_job(_parse)
|
|
|
|
|
|
async def _check_full_config(hass: HomeAssistant) -> dict[str, Any]:
|
|
try:
|
|
from homeassistant.components.config.core import async_check_ha_config_file
|
|
except ImportError:
|
|
from homeassistant.config import async_check_ha_config_file
|
|
|
|
result = await async_check_ha_config_file(hass)
|
|
if result is None:
|
|
return {"valid": True}
|
|
if isinstance(result, str):
|
|
return {"valid": not bool(result), "error": result or None}
|
|
errors = getattr(result, "errors", None)
|
|
if errors:
|
|
return {"valid": False, "error": str(errors)}
|
|
return {"valid": True, "result": str(result)}
|
|
|
|
|
|
async def _atomic_write(hass: HomeAssistant, path: Path, content: str) -> None:
|
|
def _write() -> None:
|
|
temporary = path.with_name(f".{path.name}.hass-mcp-{secrets.token_hex(6)}.tmp")
|
|
try:
|
|
with temporary.open("w", encoding="utf-8") as handle:
|
|
handle.write(content)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, path)
|
|
finally:
|
|
if temporary.exists():
|
|
temporary.unlink()
|
|
|
|
await hass.async_add_executor_job(_write)
|
|
|
|
|
|
async def _commit_text(
|
|
hass: HomeAssistant,
|
|
path: Path,
|
|
filename: str,
|
|
structure: str,
|
|
reload_domain: str | None,
|
|
before: str,
|
|
proposed: str,
|
|
) -> dict[str, Any]:
|
|
current = await _read_text(hass, path)
|
|
if _fingerprint(current) != _fingerprint(before):
|
|
raise ToolError("YAML file changed after preview; refusing stale write and requiring a new preview")
|
|
await _validate_yaml_text(hass, proposed, structure, filename)
|
|
backup_id = await _create_backup(hass, filename, before)
|
|
await _atomic_write(hass, path, proposed)
|
|
validation = await _check_full_config(hass)
|
|
if not validation["valid"]:
|
|
await _atomic_write(hass, path, before)
|
|
raise ToolError(f"Home Assistant config validation failed; original restored from {backup_id}: {validation.get('error')}")
|
|
try:
|
|
if reload_domain:
|
|
await _reload(hass, reload_domain)
|
|
except Exception:
|
|
await _atomic_write(hass, path, before)
|
|
if reload_domain:
|
|
try:
|
|
await _reload(hass, reload_domain)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
readback = await _read_text(hass, path)
|
|
return {
|
|
"changed": readback == proposed,
|
|
"file": filename,
|
|
"backup_id": backup_id,
|
|
"full_config_valid": True,
|
|
"reloaded": reload_domain,
|
|
"restart_required": reload_domain is None,
|
|
"new_sha256": _fingerprint(readback),
|
|
"exact_readback_match": readback == proposed,
|
|
}
|
|
|
|
|
|
async def _commit_data(
|
|
hass: HomeAssistant,
|
|
path: Path,
|
|
filename: str,
|
|
structure: str,
|
|
reload_domain: str | None,
|
|
before: str,
|
|
data: Any,
|
|
) -> dict[str, Any]:
|
|
from homeassistant.util.yaml import save_yaml
|
|
|
|
def _render() -> str:
|
|
temporary = path.with_name(f".{path.name}.hass-mcp-render-{secrets.token_hex(6)}.tmp")
|
|
try:
|
|
save_yaml(str(temporary), data)
|
|
return temporary.read_text(encoding="utf-8")
|
|
finally:
|
|
if temporary.exists():
|
|
temporary.unlink()
|
|
|
|
proposed = await hass.async_add_executor_job(_render)
|
|
return await _commit_text(hass, path, filename, structure, reload_domain, before, proposed)
|
|
|
|
|
|
async def _load(hass: HomeAssistant, path: Path, structure: str) -> Any:
|
|
return await _validate_yaml_text(hass, await _read_text(hass, path), structure, path.name)
|
|
|
|
|
|
async def _reload(hass: HomeAssistant, domain: str | None) -> None:
|
|
if not domain:
|
|
return
|
|
try:
|
|
await hass.services.async_call(domain, "reload", {}, blocking=True, context=user_context())
|
|
except Exception as error:
|
|
raise internal_error(f"{domain}.reload failed", error) from error
|
|
|
|
|
|
def _derive_entity_id(domain: str, structure: str, new_id: str, config: dict[str, Any]) -> str:
|
|
slug = slugify(new_id) if structure == "dict" else slugify(config.get("alias") or new_id)
|
|
return f"{domain}.{slug}"
|
|
|
|
|
|
def _to_list(data: Any, structure: str) -> list[dict[str, Any]]:
|
|
if structure == "list":
|
|
return list(data)
|
|
return [{"id": key, **value} for key, value in data.items()]
|
|
|
|
|
|
def _find(data: Any, structure: str, id: str) -> dict[str, Any] | None:
|
|
if structure == "list":
|
|
for entry in data:
|
|
if entry.get("id") == id or entry.get("alias") == id:
|
|
return entry
|
|
return None
|
|
return {"id": id, **data[id]} if id in data else None
|
|
|
|
|
|
def _replace(data: Any, structure: str, id: str, new: dict[str, Any]) -> bool:
|
|
if structure == "list":
|
|
for index, entry in enumerate(data):
|
|
if entry.get("id") == id:
|
|
data[index] = {"id": id, **{key: value for key, value in new.items() if key != "id"}}
|
|
return True
|
|
return False
|
|
if id in data:
|
|
data[id] = new
|
|
return True
|
|
return False
|
|
|
|
|
|
def _remove(data: Any, structure: str, id: str) -> bool:
|
|
if structure == "list":
|
|
for index, entry in enumerate(data):
|
|
if entry.get("id") == id:
|
|
del data[index]
|
|
return True
|
|
return False
|
|
if id in data:
|
|
del data[id]
|
|
return True
|
|
return False
|