From 2f7f49b8a0270c0c6ecc6ed6ae38715ae181ff31 Mon Sep 17 00:00:00 2001 From: Otto Date: Sat, 13 Jun 2026 20:21:08 +0200 Subject: [PATCH] AUTO-001: add automation approval workflow Closes #21 --- .env.example | 1 + CHANGELOG.md | 1 + Dockerfile | 3 +- README.md | 2 + app/api/v1/automations.py | 77 ++++++++++++++++++++ app/automations/__init__.py | 3 + app/automations/models.py | 41 +++++++++++ app/automations/store.py | 124 ++++++++++++++++++++++++++++++++ app/config.py | 2 + app/main.py | 4 ++ docker-compose.yml | 3 + docs/automations.md | 14 ++++ tests/api/test_automations.py | 59 +++++++++++++++ tests/automations/test_store.py | 53 ++++++++++++++ tests/test_config.py | 2 + 15 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 app/api/v1/automations.py create mode 100644 app/automations/__init__.py create mode 100644 app/automations/models.py create mode 100644 app/automations/store.py create mode 100644 docs/automations.md create mode 100644 tests/api/test_automations.py create mode 100644 tests/automations/test_store.py diff --git a/.env.example b/.env.example index 22758df..4346fe7 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ SILLYHOME_HA_URL=http://homeassistant.local:8123 SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN SILLYHOME_MODEL_STORE=.model_store +SILLYHOME_AUTOMATION_STORE=.automation_store diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d51578..4790af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased - Deterministische, nutzerverständliche Erklärungen für jede Modellvorhersage +- Persistenter Automation-Freigabeprozess mit sicherem YAML-Export ## 0.2.0 - 2026-06-13 - Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern diff --git a/Dockerfile b/Dockerfile index 7194abe..5bf990a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ SILLYHOME_MODEL_STORE=/app/data/models +ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations WORKDIR /app @@ -14,7 +15,7 @@ COPY app ./app COPY backend ./backend RUN python -m pip install --upgrade pip && \ python -m pip install . && \ - mkdir -p /app/data/models && \ + mkdir -p /app/data/models /app/data/automations && \ chown -R sillyhome:sillyhome /app/data EXPOSE 8000 diff --git a/README.md b/README.md index 0ae5590..11f990d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ uvicorn app.main:app --reload - `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health - `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren - `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen +- `POST http://127.0.0.1:8000/v1/automations/proposals` - sicheren Entwurf anlegen Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`. @@ -66,6 +67,7 @@ dem Netz muss ein authentifizierender Reverse Proxy vorgeschaltet werden. - `SILLYHOME_HA_URL` – Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`) - `SILLYHOME_HA_TOKEN` – Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten - `SILLYHOME_MODEL_STORE` – Verzeichnis für persistierte Modell-Metadaten +- `SILLYHOME_AUTOMATION_STORE` – Verzeichnis für Automation-Entwürfe Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins Versionskontrollsystem. diff --git a/app/api/v1/automations.py b/app/api/v1/automations.py new file mode 100644 index 0000000..21e7777 --- /dev/null +++ b/app/api/v1/automations.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request, Response, status + +from app.automations.models import ( + AutomationProposal, + ProposalDecision, + ProposalStatus, +) +from app.automations.store import AutomationStore + +router = APIRouter(prefix="/v1/automations", tags=["automations"]) + + +@router.post("/proposals", response_model=AutomationProposal, status_code=201) +def create_proposal(payload: AutomationProposal, request: Request) -> AutomationProposal: + if payload.trigger.above is None and payload.trigger.below is None: + raise HTTPException(status_code=422, detail="Trigger benötigt above oder below.") + return _store(request).create(payload.model_copy(update={"status": ProposalStatus.DRAFT})) + + +@router.get("/proposals", response_model=list[AutomationProposal]) +def list_proposals(request: Request) -> list[AutomationProposal]: + return _store(request).list() + + +@router.post("/proposals/{proposal_id}/approve", response_model=AutomationProposal) +def approve( + proposal_id: str, + payload: ProposalDecision, + request: Request, +) -> AutomationProposal: + return _decide(request, proposal_id, ProposalStatus.APPROVED, payload.expected_revision) + + +@router.post("/proposals/{proposal_id}/reject", response_model=AutomationProposal) +def reject( + proposal_id: str, + payload: ProposalDecision, + request: Request, +) -> AutomationProposal: + return _decide(request, proposal_id, ProposalStatus.REJECTED, payload.expected_revision) + + +@router.get("/proposals/{proposal_id}/yaml") +def export_yaml(proposal_id: str, request: Request) -> Response: + try: + content = _store(request).export_yaml(proposal_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return Response(content=content, media_type="application/yaml") + + +def _decide( + request: Request, + proposal_id: str, + decision: ProposalStatus, + expected_revision: int, +) -> AutomationProposal: + try: + return _store(request).decide(proposal_id, decision, expected_revision) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +def _store(request: Request) -> AutomationStore: + store = getattr(request.app.state, "automation_store", None) + if not isinstance(store, AutomationStore): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Automation Store nicht initialisiert.", + ) + return store diff --git a/app/automations/__init__.py b/app/automations/__init__.py new file mode 100644 index 0000000..e41600f --- /dev/null +++ b/app/automations/__init__.py @@ -0,0 +1,3 @@ +from app.automations.store import AutomationStore + +__all__ = ["AutomationStore"] diff --git a/app/automations/models.py b/app/automations/models.py new file mode 100644 index 0000000..814cf58 --- /dev/null +++ b/app/automations/models.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import StrEnum +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +class ProposalStatus(StrEnum): + DRAFT = "draft" + APPROVED = "approved" + REJECTED = "rejected" + + +class NumericStateTrigger(BaseModel): + entity_id: str = Field(pattern=r"^sensor\.[a-z0-9_]+$") + above: float | None = None + below: float | None = None + + +class ServiceAction(BaseModel): + service: str = Field(pattern=r"^(light|switch|climate|fan|cover)\.[a-z0-9_]+$") + entity_id: str = Field(pattern=r"^(light|switch|climate|fan|cover)\.[a-z0-9_]+$") + data: dict[str, str | int | float | bool] = Field(default_factory=dict) + + +class AutomationProposal(BaseModel): + proposal_id: str = Field(default_factory=lambda: uuid4().hex) + alias: str = Field(min_length=1, max_length=120) + description: str = Field(min_length=1, max_length=500) + trigger: NumericStateTrigger + action: ServiceAction + status: ProposalStatus = ProposalStatus.DRAFT + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + revision: int = 1 + + +class ProposalDecision(BaseModel): + expected_revision: int = Field(ge=1) diff --git a/app/automations/store.py b/app/automations/store.py new file mode 100644 index 0000000..bd1a5eb --- /dev/null +++ b/app/automations/store.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from threading import RLock + +from app.automations.models import AutomationProposal, ProposalStatus + + +class AutomationStore: + def __init__(self, root: str | Path) -> None: + self._root = Path(root).resolve() + self._root.mkdir(parents=True, exist_ok=True) + self._lock = RLock() + + def create(self, proposal: AutomationProposal) -> AutomationProposal: + with self._lock: + target = self._target(proposal.proposal_id) + if target.exists(): + raise ValueError("Automation-Vorschlag existiert bereits.") + self._persist(proposal) + return proposal + + def list(self) -> list[AutomationProposal]: + with self._lock: + return [self._load(path) for path in sorted(self._root.glob("*.json"))] + + def get(self, proposal_id: str) -> AutomationProposal: + with self._lock: + target = self._target(proposal_id) + if not target.exists(): + raise KeyError("Automation-Vorschlag nicht gefunden.") + return self._load(target) + + def decide( + self, + proposal_id: str, + status: ProposalStatus, + expected_revision: int, + ) -> AutomationProposal: + if status is ProposalStatus.DRAFT: + raise ValueError("Entscheidung darf nicht auf draft gesetzt werden.") + with self._lock: + proposal = self.get(proposal_id) + if proposal.revision != expected_revision: + raise ValueError("Revision stimmt nicht mit dem aktuellen Vorschlag überein.") + if proposal.status is not ProposalStatus.DRAFT: + raise ValueError("Über den Vorschlag wurde bereits entschieden.") + updated = proposal.model_copy( + update={ + "status": status, + "updated_at": datetime.now(timezone.utc), + "revision": proposal.revision + 1, + } + ) + self._persist(updated) + return updated + + def export_yaml(self, proposal_id: str) -> str: + proposal = self.get(proposal_id) + if proposal.status is not ProposalStatus.APPROVED: + raise ValueError("Nur freigegebene Vorschläge dürfen exportiert werden.") + trigger_lines = [ + "trigger:", + " - platform: numeric_state", + f" entity_id: {proposal.trigger.entity_id}", + ] + if proposal.trigger.above is not None: + trigger_lines.append(f" above: {proposal.trigger.above}") + if proposal.trigger.below is not None: + trigger_lines.append(f" below: {proposal.trigger.below}") + action_lines = [ + "action:", + f" - service: {proposal.action.service}", + " target:", + f" entity_id: {proposal.action.entity_id}", + ] + if proposal.action.data: + action_lines.append(" data:") + action_lines.extend( + f" {key}: {_yaml_scalar(value)}" + for key, value in sorted(proposal.action.data.items()) + ) + return "\n".join( + [ + f"alias: {_yaml_scalar(proposal.alias)}", + f"description: {_yaml_scalar(proposal.description)}", + *trigger_lines, + *action_lines, + "mode: single", + "", + ] + ) + + def _target(self, proposal_id: str) -> Path: + if len(proposal_id) != 32 or not proposal_id.isalnum(): + raise ValueError("Ungültige proposal_id.") + return self._root / f"{proposal_id}.json" + + def _persist(self, proposal: AutomationProposal) -> None: + target = self._target(proposal.proposal_id) + temporary = target.with_suffix(".json.tmp") + temporary.write_text( + json.dumps(proposal.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.replace(temporary, target) + + @staticmethod + def _load(path: Path) -> AutomationProposal: + try: + return AutomationProposal.model_validate_json(path.read_text(encoding="utf-8")) + except ValueError as exc: + raise ValueError(f"Ungültiger Automation-Vorschlag: {path.name}") from exc + + +def _yaml_scalar(value: str | int | float | bool) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + return json.dumps(value, ensure_ascii=True) diff --git a/app/config.py b/app/config.py index 24de378..6341344 100644 --- a/app/config.py +++ b/app/config.py @@ -9,6 +9,7 @@ class Settings: ha_url: str | None = None ha_token: str | None = None model_store: str = ".model_store" + automation_store: str = ".automation_store" @property def ha_configured(self) -> bool: @@ -20,4 +21,5 @@ def load_settings() -> Settings: ha_url=os.getenv("SILLYHOME_HA_URL") or os.getenv("HA_URL"), ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"), model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"), + automation_store=os.getenv("SILLYHOME_AUTOMATION_STORE", ".automation_store"), ) diff --git a/app/main.py b/app/main.py index 0df65ed..49423e5 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,8 @@ from typing import cast from fastapi import FastAPI from app.api.v1.entities import router as entities_router +from app.api.v1.automations import router as automations_router +from app.automations.store import AutomationStore from app.config import load_settings from app.core.exception_handlers import register_exception_handlers from app.ha.client import HaClient, HaClientSettings @@ -18,6 +20,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = app.state.settings client: HaClient | None = None app.state.registry = ModelRegistry(settings.model_store) + app.state.automation_store = AutomationStore(settings.automation_store) if hasattr(app.state, "ha_reader"): del app.state.ha_reader if settings.ha_configured: @@ -44,6 +47,7 @@ app = FastAPI( app.state.settings = load_settings() register_exception_handlers(app) app.include_router(entities_router) +app.include_router(automations_router) init_ml_routes(app, model_store=app.state.settings.model_store) diff --git a/docker-compose.yml b/docker-compose.yml index 7a12712..76ff008 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,10 @@ services: required: false environment: SILLYHOME_MODEL_STORE: /app/data/models + SILLYHOME_AUTOMATION_STORE: /app/data/automations volumes: - model-data:/app/data/models + - automation-data:/app/data/automations read_only: true tmpfs: - /tmp @@ -21,3 +23,4 @@ services: volumes: model-data: + automation-data: diff --git a/docs/automations.md b/docs/automations.md new file mode 100644 index 0000000..f462691 --- /dev/null +++ b/docs/automations.md @@ -0,0 +1,14 @@ +# Automation-Vorschläge + +SillyHome Next führt Automationen niemals automatisch aus. Der Workflow ist: + +1. Vorschlag als `draft` erstellen. +2. Inhalt und Ziel-Entity prüfen. +3. Mit aktueller Revision explizit freigeben oder ablehnen. +4. Nur freigegebene Vorschläge als Home-Assistant-YAML exportieren. +5. Das YAML außerhalb von SillyHome Next in Home Assistant importieren. + +Erlaubt sind numerische Sensor-Trigger und Aktionsdienste aus den Domains +`light`, `switch`, `climate`, `fan` und `cover`. Shell-Kommandos, Skripte und +beliebige Service-Domains werden abgewiesen. Eine einmal getroffene Entscheidung +kann nicht überschrieben werden; Änderungen benötigen einen neuen Vorschlag. diff --git a/tests/api/test_automations.py b/tests/api/test_automations.py new file mode 100644 index 0000000..1342df0 --- /dev/null +++ b/tests/api/test_automations.py @@ -0,0 +1,59 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.automations.store import AutomationStore +from app.main import app + + +def _payload() -> dict[str, object]: + return { + "alias": "Licht bei Dunkelheit", + "description": "Schaltet das Flurlicht unter dem Helligkeitsgrenzwert ein.", + "trigger": {"entity_id": "sensor.hall_illuminance", "below": 10}, + "action": { + "service": "light.turn_on", + "entity_id": "light.hall", + "data": {"brightness_pct": 40}, + }, + } + + +def test_proposal_requires_explicit_approval_before_yaml(tmp_path: Path) -> None: + with TestClient(app) as client: + app.state.automation_store = AutomationStore(tmp_path) + created = client.post("/v1/automations/proposals", json=_payload()) + proposal_id = created.json()["proposal_id"] + blocked = client.get(f"/v1/automations/proposals/{proposal_id}/yaml") + approved = client.post( + f"/v1/automations/proposals/{proposal_id}/approve", + json={"expected_revision": 1}, + ) + exported = client.get(f"/v1/automations/proposals/{proposal_id}/yaml") + assert created.status_code == 201 + assert created.json()["status"] == "draft" + assert blocked.status_code == 409 + assert approved.json()["status"] == "approved" + assert "service: light.turn_on" in exported.text + + +def test_proposal_rejects_unsafe_service_domain(tmp_path: Path) -> None: + payload = _payload() + payload["action"] = { + "service": "shell_command.run", + "entity_id": "light.hall", + "data": {}, + } + with TestClient(app) as client: + app.state.automation_store = AutomationStore(tmp_path) + response = client.post("/v1/automations/proposals", json=payload) + assert response.status_code == 422 + + +def test_proposal_requires_numeric_threshold(tmp_path: Path) -> None: + payload = _payload() + payload["trigger"] = {"entity_id": "sensor.hall_illuminance"} + with TestClient(app) as client: + app.state.automation_store = AutomationStore(tmp_path) + response = client.post("/v1/automations/proposals", json=payload) + assert response.status_code == 422 diff --git a/tests/automations/test_store.py b/tests/automations/test_store.py new file mode 100644 index 0000000..4fc3483 --- /dev/null +++ b/tests/automations/test_store.py @@ -0,0 +1,53 @@ +from pathlib import Path + +import pytest + +from app.automations.models import ( + AutomationProposal, + NumericStateTrigger, + ProposalStatus, + ServiceAction, +) +from app.automations.store import AutomationStore + + +def proposal() -> AutomationProposal: + return AutomationProposal( + alias="Wohnzimmer bei Kälte heizen", + description="Aktiviert den Heizmodus unter 18 Grad.", + trigger=NumericStateTrigger(entity_id="sensor.living_room_temperature", below=18.0), + action=ServiceAction( + service="climate.set_temperature", + entity_id="climate.living_room", + data={"temperature": 21.0}, + ), + ) + + +def test_store_persists_approval_and_exports_yaml(tmp_path: Path) -> None: + store = AutomationStore(tmp_path) + created = store.create(proposal()) + approved = store.decide(created.proposal_id, ProposalStatus.APPROVED, 1) + yaml = AutomationStore(tmp_path).export_yaml(created.proposal_id) + assert approved.status is ProposalStatus.APPROVED + assert approved.revision == 2 + assert "platform: numeric_state" in yaml + assert "service: climate.set_temperature" in yaml + assert "temperature: 21.0" in yaml + + +def test_store_requires_approval_and_current_revision(tmp_path: Path) -> None: + store = AutomationStore(tmp_path) + created = store.create(proposal()) + with pytest.raises(ValueError, match="freigegebene"): + store.export_yaml(created.proposal_id) + with pytest.raises(ValueError, match="Revision"): + store.decide(created.proposal_id, ProposalStatus.APPROVED, 2) + + +def test_store_allows_only_one_decision(tmp_path: Path) -> None: + store = AutomationStore(tmp_path) + created = store.create(proposal()) + store.decide(created.proposal_id, ProposalStatus.REJECTED, 1) + with pytest.raises(ValueError, match="bereits entschieden"): + store.decide(created.proposal_id, ProposalStatus.APPROVED, 2) diff --git a/tests/test_config.py b/tests/test_config.py index e5a9525..1b6c010 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,10 +9,12 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) -> monkeypatch.setenv("SILLYHOME_HA_URL", "http://ha.local:8123") monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret") monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models") + monkeypatch.setenv("SILLYHOME_AUTOMATION_STORE", "/tmp/automations") settings = load_settings() assert settings.ha_url == "http://ha.local:8123" assert settings.ha_token == "secret" assert settings.model_store == "/tmp/models" + assert settings.automation_store == "/tmp/automations" assert settings.ha_configured