Compare commits

...

6 Commits

Author SHA1 Message Date
d6631fe752 OPS-001: persist HA panel and rollback instructions
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 21:18:24 +02:00
9f4fc2f4ce Merge pull request 'MVP: Dashboard and Home Assistant add-on' (#29) from feature/mvp-testable into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 21:12:31 +02:00
5764b27bac MVP: add dashboard and Home Assistant add-on
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-13 21:12:02 +02:00
9ddb86cc1a Merge pull request 'AUTO-001: Safe Automation Approval Workflow' (#28) from feature/automation-approval into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 20:21:22 +02:00
2f7f49b8a0 AUTO-001: add automation approval workflow
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
Closes #21
2026-06-13 20:21:08 +02:00
6f9b5ea48f Merge pull request 'ML-009: Explainable Predictions' (#27) from feature/ml-explanations into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 20:16:40 +02:00
22 changed files with 632 additions and 5 deletions

View File

@@ -1,3 +1,4 @@
SILLYHOME_HA_URL=http://homeassistant.local:8123 SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
SILLYHOME_MODEL_STORE=.model_store SILLYHOME_MODEL_STORE=.model_store
SILLYHOME_AUTOMATION_STORE=.automation_store

View File

@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- Deterministische, nutzerverständliche Erklärungen für jede Modellvorhersage - Deterministische, nutzerverständliche Erklärungen für jede Modellvorhersage
- Persistenter Automation-Freigabeprozess mit sicherem YAML-Export
## 0.2.0 - 2026-06-13 ## 0.2.0 - 2026-06-13
- Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern - Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern

View File

@@ -4,6 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
SILLYHOME_MODEL_STORE=/app/data/models SILLYHOME_MODEL_STORE=/app/data/models
ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations
WORKDIR /app WORKDIR /app
@@ -14,7 +15,7 @@ COPY app ./app
COPY backend ./backend COPY backend ./backend
RUN python -m pip install --upgrade pip && \ RUN python -m pip install --upgrade pip && \
python -m pip install . && \ python -m pip install . && \
mkdir -p /app/data/models && \ mkdir -p /app/data/models /app/data/automations && \
chown -R sillyhome:sillyhome /app/data chown -R sillyhome:sillyhome /app/data
EXPOSE 8000 EXPOSE 8000

View File

@@ -40,6 +40,7 @@ uvicorn app.main:app --reload
``` ```
4. Erreichbar unter: 4. Erreichbar unter:
- `http://127.0.0.1:8000/` - lokales Dashboard
- `http://127.0.0.1:8000/health` - Health-Check - `http://127.0.0.1:8000/health` - Health-Check
- `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation - `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation
- `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities - `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities
@@ -48,6 +49,7 @@ uvicorn app.main:app --reload
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health - `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/retrain` - Modell-Metadaten aktualisieren
- `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen - `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`. Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`.
@@ -66,10 +68,27 @@ 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_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_HA_TOKEN` Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten
- `SILLYHOME_MODEL_STORE` Verzeichnis für persistierte Modell-Metadaten - `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 Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
Versionskontrollsystem. Versionskontrollsystem.
### Home-Assistant-Add-on
Das Repository ist zugleich ein Home-Assistant-Add-on-Repository. In Home Assistant
unter **Einstellungen → Add-ons → Add-on-Shop → Repositories** diese URL eintragen:
`http://192.168.6.31:3000/pino/sillyhome-next`
Danach **SillyHome Next** installieren und starten. Das Dashboard wird per Ingress
geöffnet. Das Add-on nutzt die Supervisor-API nur lesend; Automation-Entwürfe werden
lokal gespeichert und niemals automatisch ausgeführt.
Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups**
eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte
Backup öffnen, **SillyHome Next** auswählen und wiederherstellen. Der erste produktive
Teststand `v0.3.0` wurde als HA-Backup `7df0fca0` gesichert.
### Tests ### Tests
```bash ```bash
pytest pytest

19
addon/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& git clone --depth 1 --branch main \
http://192.168.6.31:3000/pino/sillyhome-next.git /app \
&& python -m pip install --upgrade pip \
&& python -m pip install /app \
&& rm -rf /var/lib/apt/lists/* /app/.git
COPY run.sh /run.sh
RUN chmod 0755 /run.sh
EXPOSE 8000
CMD ["/run.sh"]

23
addon/config.yaml Normal file
View File

@@ -0,0 +1,23 @@
name: SillyHome Next
version: "0.3.0"
slug: sillyhome_next
description: Lokale HA-Analyse, Vorhersagen und sichere Automation-Entwürfe
url: http://192.168.6.31:3000/pino/sillyhome-next
arch:
- amd64
startup: application
boot: auto
init: false
ingress: true
ingress_port: 8000
panel_title: SillyHome Next
panel_icon: mdi:home-analytics
panel_admin: true
homeassistant_api: true
hassio_api: false
auth_api: false
options: {}
schema: {}
map:
- type: addon_config
read_only: false

11
addon/run.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
export SILLYHOME_HA_URL="${SILLYHOME_HA_URL:-http://supervisor/core}"
export SILLYHOME_HA_TOKEN="${SILLYHOME_HA_TOKEN:-${SUPERVISOR_TOKEN:-}}"
export SILLYHOME_MODEL_STORE=/data/models
export SILLYHOME_AUTOMATION_STORE=/data/automations
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE"
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*'

77
app/api/v1/automations.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,3 @@
from app.automations.store import AutomationStore
__all__ = ["AutomationStore"]

41
app/automations/models.py Normal file
View File

@@ -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)

124
app/automations/store.py Normal file
View File

@@ -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)

View File

@@ -9,6 +9,7 @@ class Settings:
ha_url: str | None = None ha_url: str | None = None
ha_token: str | None = None ha_token: str | None = None
model_store: str = ".model_store" model_store: str = ".model_store"
automation_store: str = ".automation_store"
@property @property
def ha_configured(self) -> bool: 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_url=os.getenv("SILLYHOME_HA_URL") or os.getenv("HA_URL"),
ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"), ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"),
model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"), model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"),
automation_store=os.getenv("SILLYHOME_AUTOMATION_STORE", ".automation_store"),
) )

View File

@@ -1,10 +1,15 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path
from typing import cast from typing import cast
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.v1.entities import router as entities_router 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.config import load_settings
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
@@ -18,6 +23,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings settings = app.state.settings
client: HaClient | None = None client: HaClient | None = None
app.state.registry = ModelRegistry(settings.model_store) app.state.registry = ModelRegistry(settings.model_store)
app.state.automation_store = AutomationStore(settings.automation_store)
if hasattr(app.state, "ha_reader"): if hasattr(app.state, "ha_reader"):
del app.state.ha_reader del app.state.ha_reader
if settings.ha_configured: if settings.ha_configured:
@@ -38,14 +44,18 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.2.0", version="0.3.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
register_exception_handlers(app) register_exception_handlers(app)
app.include_router(entities_router) app.include_router(entities_router)
app.include_router(automations_router)
init_ml_routes(app, model_store=app.state.settings.model_store) init_ml_routes(app, model_store=app.state.settings.model_store)
STATIC_DIR = Path(__file__).with_name("static")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/health") @app.get("/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
@@ -53,5 +63,5 @@ def health() -> dict[str, str]:
@app.get("/") @app.get("/")
def root() -> dict[str, str]: def root() -> FileResponse:
return {"service": "sillyhome-next", "docs": "/docs"} return FileResponse(STATIC_DIR / "index.html")

149
app/static/index.html Normal file
View File

@@ -0,0 +1,149 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SillyHome Next</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
body { margin: 0; }
header { padding: 20px; background: linear-gradient(135deg,#142b3a,#193f36); }
h1,h2 { margin: 0 0 12px; }
header p { margin: 4px 0; color: #b9c9d6; }
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(310px,1fr)); gap: 14px; padding: 14px; }
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
.wide { grid-column: 1 / -1; }
.ok { color: #66dfa9; } .bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
input,select,textarea,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 9px; background: #101820; color: #fff; }
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; }
pre { white-space: pre-wrap; max-height: 310px; overflow: auto; background: #0d141b; padding: 10px; border-radius: 7px; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
td,th { padding: 7px; border-bottom: 1px solid #2d3a47; text-align: left; }
.notice { border-left: 4px solid #e8b34b; padding-left: 10px; }
</style>
</head>
<body>
<header>
<h1>SillyHome Next</h1>
<p>Lokale Home-Assistant-Analyse, Vorhersagen und kontrollierte Automation-Entwürfe.</p>
<p class="notice">Sicherheitsmodus: Entwürfe werden niemals automatisch in Home Assistant ausgeführt.</p>
</header>
<main>
<section>
<h2>Systemstatus</h2>
<div id="status">Prüfung läuft ...</div>
<button class="secondary" onclick="loadStatus()">Neu laden</button>
</section>
<section>
<h2>Entity Discovery</h2>
<label for="domain">Domain (optional)</label>
<input id="domain" placeholder="sensor">
<button onclick="discover()">HA-Entities analysieren</button>
<pre id="discovery">Noch nicht geladen.</pre>
</section>
<section>
<h2>Modell trainieren</h2>
<label for="train-model">Modell-ID</label><input id="train-model" value="home-model">
<label for="train-sensor">Sensor</label><input id="train-sensor" placeholder="sensor.temperatur">
<label for="train-feature">Merkmal</label><input id="train-feature" value="value">
<label for="train-values">Messwerte, komma-getrennt</label><input id="train-values" placeholder="19,20,21">
<button onclick="train()">Trainieren</button>
<pre id="training">Bereit.</pre>
</section>
<section>
<h2>Vorhersage</h2>
<label for="predict-model">Modell-ID</label><input id="predict-model" value="home-model">
<label for="predict-sensor">Sensor</label><input id="predict-sensor" placeholder="sensor.temperatur">
<label for="predict-feature">Merkmal</label><input id="predict-feature" value="value">
<label for="predict-value">Aktueller Wert</label><input id="predict-value" type="number" step="any">
<button onclick="predict()">Vorhersagen und erklären</button>
<pre id="prediction">Bereit.</pre>
</section>
<section class="wide">
<h2>Automation-Entwurf</h2>
<p>Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
<div><label for="alias">Name</label><input id="alias" value="Licht bei Dunkelheit"></div>
<div><label for="trigger">Trigger-Entity</label><input id="trigger" placeholder="sensor.flur_illuminance"></div>
<div><label for="below">Unter Grenzwert</label><input id="below" type="number" value="10"></div>
<div><label for="service">Dienst</label><select id="service"><option>light.turn_on</option><option>light.turn_off</option><option>switch.turn_on</option><option>switch.turn_off</option></select></div>
<div><label for="target">Ziel-Entity</label><input id="target" placeholder="light.flur"></div>
</div>
<button onclick="createProposal()">Entwurf speichern</button>
<button class="secondary" onclick="loadProposals()">Entwürfe aktualisieren</button>
<div id="proposals"></div>
</section>
</main>
<script>
const pretty = value => JSON.stringify(value, null, 2);
async function api(path, options={}) {
const response = await fetch(path, {headers: {"Content-Type":"application/json"}, ...options});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.detail || `${response.status} ${response.statusText}`);
return body;
}
async function loadStatus() {
const box=document.getElementById("status");
try {
const [health, ml, models]=await Promise.all([api("health"),api("ml/health"),api("ml/models")]);
box.innerHTML=`<p class="ok">API und ML bereit</p><p>Modelle: ${models.models.length}</p>`;
} catch(e) { box.innerHTML=`<p class="bad">${e.message}</p>`; }
}
async function discover() {
const out=document.getElementById("discovery"), domain=document.getElementById("domain").value.trim();
out.textContent="Lade ...";
try {
const rows=await api(`v1/discovery?learnable=true${domain?`&domain=${encodeURIComponent(domain)}`:""}`);
out.textContent=pretty({learnable_entities:rows.length, entities:rows.slice(0,100)});
} catch(e) { out.textContent=e.message; }
}
async function train() {
const out=document.getElementById("training");
try {
const values=document.getElementById("train-values").value.split(",").map(Number).filter(Number.isFinite);
if (!values.length) throw new Error("Mindestens einen Messwert eingeben.");
const sensor=document.getElementById("train-sensor").value.trim(), feature=document.getElementById("train-feature").value.trim();
const samples=values.map(value=>({sensor_id:sensor,values:{[feature]:value}}));
out.textContent=pretty(await api("ml/retrain",{method:"POST",body:JSON.stringify({modelId:document.getElementById("train-model").value,samples})}));
await loadStatus();
} catch(e) { out.textContent=e.message; }
}
async function predict() {
const out=document.getElementById("prediction");
try {
const feature=document.getElementById("predict-feature").value.trim();
out.textContent=pretty(await api("ml/predict",{method:"POST",body:JSON.stringify({
modelId:document.getElementById("predict-model").value,
sensor_id:document.getElementById("predict-sensor").value.trim(),
values:{[feature]:Number(document.getElementById("predict-value").value)}
})}));
} catch(e) { out.textContent=e.message; }
}
async function createProposal() {
try {
await api("v1/automations/proposals",{method:"POST",body:JSON.stringify({
alias:document.getElementById("alias").value,
description:"Manuell im SillyHome-Dashboard erstellter und nicht automatisch ausgeführter Entwurf.",
trigger:{entity_id:document.getElementById("trigger").value,below:Number(document.getElementById("below").value)},
action:{service:document.getElementById("service").value,entity_id:document.getElementById("target").value,data:{}}
})});
await loadProposals();
} catch(e) { alert(e.message); }
}
async function decide(id, revision, action) {
try { await api(`v1/automations/proposals/${id}/${action}`,{method:"POST",body:JSON.stringify({expected_revision:revision})}); await loadProposals(); }
catch(e) { alert(e.message); }
}
async function loadProposals() {
const box=document.getElementById("proposals");
try {
const rows=await api("v1/automations/proposals");
box.innerHTML=rows.length?`<table><tr><th>Name</th><th>Status</th><th>Aktion</th></tr>${rows.map(x=>`<tr><td>${x.alias}</td><td>${x.status}</td><td>${x.status==="draft"?`<button onclick="decide('${x.proposal_id}',${x.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${x.proposal_id}',${x.revision},'reject')">Ablehnen</button>`:`${x.status==="approved"?`<a href="v1/automations/proposals/${x.proposal_id}/yaml">YAML laden</a>`:"-"}`}</td></tr>`).join("")}</table>`:"<p>Keine Entwürfe.</p>";
} catch(e) { box.textContent=e.message; }
}
loadStatus(); loadProposals();
</script>
</body>
</html>

View File

@@ -8,8 +8,10 @@ services:
required: false required: false
environment: environment:
SILLYHOME_MODEL_STORE: /app/data/models SILLYHOME_MODEL_STORE: /app/data/models
SILLYHOME_AUTOMATION_STORE: /app/data/automations
volumes: volumes:
- model-data:/app/data/models - model-data:/app/data/models
- automation-data:/app/data/automations
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp - /tmp
@@ -21,3 +23,4 @@ services:
volumes: volumes:
model-data: model-data:
automation-data:

14
docs/automations.md Normal file
View File

@@ -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.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.2.0" version = "0.3.0"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

3
repository.yaml Normal file
View File

@@ -0,0 +1,3 @@
name: SillyHome Next Add-ons
url: http://192.168.6.31:3000/pino/sillyhome-next
maintainer: Pino

View File

@@ -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

View File

@@ -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)

View File

@@ -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_URL", "http://ha.local:8123")
monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret") monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret")
monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models") monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")
monkeypatch.setenv("SILLYHOME_AUTOMATION_STORE", "/tmp/automations")
settings = load_settings() settings = load_settings()
assert settings.ha_url == "http://ha.local:8123" assert settings.ha_url == "http://ha.local:8123"
assert settings.ha_token == "secret" assert settings.ha_token == "secret"
assert settings.model_store == "/tmp/models" assert settings.model_store == "/tmp/models"
assert settings.automation_store == "/tmp/automations"
assert settings.ha_configured assert settings.ha_configured

12
tests/test_dashboard.py Normal file
View File

@@ -0,0 +1,12 @@
from fastapi.testclient import TestClient
from app.main import app
def test_dashboard_is_served_at_root() -> None:
with TestClient(app) as client:
response = client.get("/")
assert response.status_code == 200
assert "SillyHome Next" in response.text
assert "Automation-Entwurf" in response.text