Compare commits

..

1 Commits

Author SHA1 Message Date
f4f23796d5 DOC-005: Quickstart ENV-Doku hinzufügen
- .env.example mit SILLYHOME_HA_URL und SILLYHOME_HA_TOKEN
- README: Quickstart-Sektion mit Installation, Start, ENV, Prüfung, Tests
- Hinweis zu Secrets und .gitignore
2026-06-11 03:48:33 +02:00
42 changed files with 87 additions and 1223 deletions

View File

@@ -1,17 +0,0 @@
.env
.env.*
!.env.example
.venv
.venv/*
__pycache__
.mypy_cache
.pytest_cache
.ruff_cache
node_modules
.idea
.vscode
.git
.gitignore
README.md
.dockerignore
docker-compose*.yml

View File

@@ -1,2 +1,3 @@
SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
# Home Assistant Zugriff
SILLYHOME_HA_URL=http://localhost:8123
SILLYHOME_HA_TOKEN=dein_long_lived_access_token

View File

@@ -1,15 +0,0 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml ./
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir -e ".[dev]"
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -13,41 +13,33 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert
## APPENDIX
## Quickstart (lokaler Betrieb)
### Quickstart
1. Python-Venv anlegen und Abhängigkeiten installieren:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .
```
1. **Voraussetzungen**
- Python 3.11+
- Home Assistant mit REST-API erreichbar
- `pip install -e .[dev]`
2. Konfiguration aus `.env.example` übernehmen und anpassen:
```bash
cp .env.example .env
```
2. **Umgebungsvariablen** (`.env` im Projektroot)
```
SILLYHOME_HA_URL=http://localhost:8123
SILLYHOME_HA_TOKEN=dein_long_lived_access_token
```
Tipp: `.env.example` kopieren und anpassen. Tokens niemals committen!
3. API starten:
```bash
uvicorn app.main:app --reload
```
3. **Server starten**
```
uvicorn app.main:app --reload
```
4. Erreichbar unter:
- `http://127.0.0.1:8000/health` - Health-Check
- `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/ml/health` - ML-Serving Health (ab ML-005)
4. **Prüfen**
- OpenAPI-Docs: http://localhost:8000/docs
- Health: http://localhost:8000/health
- Entities: http://localhost:8000/v1/entities (benötigt gültige HA-Konfiguration)
### ENV-Konfiguration (`.env.example`)
- `SILLYHOME_HA_URL` Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`)
- `SILLYHOME_HA_TOKEN` Long-Lived Access Token aus Home Assistant (nur lesen)
Hinweis: Nutze ausschließlich Long-Lived Access Tokens mit Leserechten. Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht in Versionskontrollsysteme.
### Tests
```bash
pytest -q
ruff check .
mypy app tests
```
5. **Tests**
```
pytest -q
ruff check .
mypy app tests
```

View File

@@ -1 +0,0 @@
"""SillyHome Next application package."""

View File

@@ -1 +0,0 @@
"""API package."""

View File

@@ -1 +0,0 @@
"""Version 1 API package."""

View File

@@ -1,39 +1,19 @@
from __future__ import annotations
from typing import List
from typing import List, Sequence
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.rules.recommender import Recommender
router = APIRouter(prefix="/v1", tags=["entities"])
def _state_ha_reader(request: Request) -> HaReader:
try:
return request.app.state.ha_reader
except AttributeError as exc:
raise HTTPException(status_code=503, detail="HA-Reader nicht initialisiert.") from exc
def _state_recommender(request: Request) -> Recommender:
try:
return request.app.state.recommender
except AttributeError as exc:
raise HTTPException(status_code=503, detail="Recommender nicht initialisiert.") from exc
@router.get(
"/entities",
summary="Home-Assistant-Entities auflisten",
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
response_model=List[HaEntitySummary],
)
def list_entities(request: Request) -> List[HaEntitySummary]:
ha_reader = _state_ha_reader(request)
recommender = _state_recommender(request)
entities = ha_reader.read_entities()
recommender.run(entities)
return entities
def list_entities() -> Sequence[HaEntitySummary]:
raise NotImplementedError("Integration mit dem HA-Client folgt in separatem Issue.")

View File

@@ -1,21 +0,0 @@
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
ha_url: str | None = None
ha_token: str | None = None
@property
def ha_configured(self) -> bool:
return bool(self.ha_url and self.ha_token)
def load_settings() -> Settings:
return 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"),
)

View File

@@ -1 +0,0 @@
"""Core application helpers."""

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError, HaTimeoutError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HaClientError)
async def handle_ha_client_error(_: Request, exc: HaClientError) -> JSONResponse:
return JSONResponse(
status_code=_status_code_for_ha_error(exc),
content={"detail": exc.public_detail},
)
def _status_code_for_ha_error(exc: HaClientError) -> int:
if isinstance(exc, HaTimeoutError):
return status.HTTP_504_GATEWAY_TIMEOUT
if isinstance(exc, (HaAuthError, HaHttpError)):
return status.HTTP_502_BAD_GATEWAY
return status.HTTP_502_BAD_GATEWAY

View File

@@ -1,20 +0,0 @@
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Request
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HaClientError)
async def handle_ha_client_error(request: Request, exc: HaClientError) -> Any: # pragma: no cover - einfacher Wrapper
if isinstance(exc, HaAuthError):
return {"detail": "Ungültige Authentifizierung gegenüber Home Assistant."}
if isinstance(exc, HaHttpError):
return {
"detail": "Home Assistant meldet einen Fehler.",
"upstream_status": exc.status_code,
}
return {"detail": str(exc)}

View File

@@ -1,15 +0,0 @@
from __future__ import annotations
from fastapi import HTTPException, Request, status
from app.ha.reader import HaReader
def get_ha_reader(request: Request) -> HaReader:
reader = getattr(request.app.state, "ha_reader", None)
if not isinstance(reader, HaReader):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Home Assistant is not configured.",
)
return reader

View File

@@ -5,13 +5,6 @@ from dataclasses import dataclass
import requests
from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
logger = logging.getLogger(__name__)
@@ -32,43 +25,9 @@ class HaClient:
})
def list_entities(self) -> list[dict[str, object]]:
try:
response = self._session.get(
f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds,
)
except requests.Timeout as exc:
raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
except requests.RequestException as exc:
raise HaHttpError(
getattr(getattr(exc, "response", None), "status_code", 502),
"Netzwerkfehler beim Zugriff auf Home Assistant.",
) from exc
if response.status_code in (401, 403):
raise HaAuthError(
response.status_code,
"Authentifizierung bei Home Assistant fehlgeschlagen.",
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise HaHttpError(
response.status_code,
"Home Assistant meldet einen Fehler.",
) from exc
try:
payload = response.json()
except ValueError as exc:
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant ist kein gültiges JSON."
) from exc
if not isinstance(payload, list):
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant hat unerwartetes Format."
)
return payload
response = self._session.get(
f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds,
)
response.raise_for_status()
return response.json()

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
class HaClientError(Exception):
"""Basisklasse für HA-Client-Fehler."""
public_detail: str | None = None
class HaTimeoutError(HaClientError):
"""Zeitüberschreitung bei Request an Home Assistant."""
public_detail = "Home Assistant request timed out."
class HaHttpError(HaClientError):
"""Nicht erfolgreicher HTTP-Statuscode."""
public_detail = "Home Assistant request failed."
def __init__(self, status_code: int, message: str = "") -> None:
super().__init__(message)
self.status_code = status_code
class HaAuthError(HaHttpError):
"""Authentifizierung oder Berechtigung fehlgeschlagen."""
public_detail = "Home Assistant authentication failed."
class HaUnexpectedPayloadError(HaClientError):
"""Antwort hat nicht das erwartete Format."""
public_detail = "Home Assistant returned an unexpected payload."

View File

@@ -1,10 +1,9 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from app.ha.client import HaClient
from app.ha.models import HaEntitySummary
from app.ha.models import HaEntitySummary, HaState
class HaReader:
@@ -19,21 +18,14 @@ class HaReader:
if "." not in entity_id:
continue
domain = entity_id.split(".", 1)[0]
raw_attributes = item.get("attributes") or {}
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
attributes = item.get("attributes") or {}
summaries.append(
HaEntitySummary(
entity_id=entity_id,
domain=domain,
state_class=_optional_str(attributes.get("state_class")),
device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
state_class=str(attributes.get("state_class") or ""),
device_class=str(attributes.get("device_class") or ""),
unit_of_measurement=str(attributes.get("unit_of_measurement") or ""),
)
)
return summaries
def _optional_str(value: object) -> str | None:
if value is None or value == "":
return None
return str(value)

View File

@@ -1,45 +1,44 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Depends
from app.api.v1.entities import router as entities_router
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
from app.rules.recommender import Recommender
from app.rules.heating import HeatingRule
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = app.state.settings
ha_url = getattr(settings, "ha_url", None)
ha_token = getattr(settings, "ha_token", None)
client = HaClient(
settings=HaClientSettings(
url=ha_url or "",
token=ha_token or "",
)
settings = HaClientSettings(
url=app.state.settings.ha_url,
token=app.state.settings.ha_token,
)
client = HaClient(settings=settings)
app.state.ha_reader = HaReader(client=client)
app.state.recommender = Recommender(rules=[HeatingRule()])
yield
class Settings:
ha_url: str = "http://localhost:8123"
ha_token: str = ""
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.1.0",
lifespan=lifespan,
)
class Settings:
ha_url: str
ha_token: str
app.state.settings = Settings()
register_exception_handlers(app)
app.include_router(entities_router)
def get_ha_reader() -> HaReader:
return app.state.ha_reader
app.include_router(entities_router, dependencies=[Depends(get_ha_reader)])
@app.get("/health")

View File

@@ -1,5 +0,0 @@
"""Machine-Learning-Grundbausteine für SillyHome Next."""
__all__ = ["FeatureStore", "FeatureVector"]
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainedArtifact, TrainingPipeline

View File

@@ -1,57 +0,0 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Sequence
from app.ml.feature_store import FeatureVector
from app.ml.training import TrainingPipeline, TrainedArtifact
logger = logging.getLogger(__name__)
@dataclass
class Metric:
name: str
value: float
threshold: float | None = None
@dataclass
class EvalReport:
artifact_id: str
sample_size: int
metrics: list[Metric]
class Evaluator:
def __init__(self, pipeline: TrainingPipeline) -> None:
self._pipeline = pipeline
def evaluate(self, artifact_id: str, predictions: Sequence[str]) -> EvalReport:
artifacts = list(self._pipeline._artifacts)
if not artifacts:
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.")
supported_sensors = self._pipeline.export(artifact_id).supported_sensors
unknown_hits = sum(1 for prediction in predictions if ":" not in prediction)
supported_references = sum(1 for sensor in supported_sensors for prediction in predictions if sensor in prediction)
sample_size = len(predictions)
coverage = supported_references / sample_size if sample_size else 0.0
unknown_rate = unknown_hits / sample_size if sample_size else 0.0
coverage_metric = Metric(name="coverage", value=coverage, threshold=0.8)
unknown_metric = Metric(name="unknown_rate", value=unknown_rate, threshold=0.1)
report = EvalReport(
artifact_id=artifact_id,
sample_size=sample_size,
metrics=[coverage_metric, unknown_metric],
)
logger.info(
"Evaluation %s -> coverage=%.2f, unknown_rate=%.2f",
artifact_id,
coverage,
unknown_rate,
)
return report

View File

@@ -1,31 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Iterable
@dataclass(frozen=True)
class FeatureVector:
sensor_id: str
values: dict[str, float]
label: str | None = None
class FeatureStore:
def __init__(self) -> None:
self._vectors: dict[str, list[FeatureVector]] = defaultdict(list)
def add(self, vector: FeatureVector) -> None:
self._vectors[vector.sensor_id].append(vector)
def add_batch(self, vectors: Iterable[FeatureVector]) -> None:
for vector in vectors:
self.add(vector)
def latest(self, sensor_id: str) -> FeatureVector | None:
series = self._vectors.get(sensor_id)
return series[-1] if series else None
def all(self) -> list[FeatureVector]:
return [vector for vectors in self._vectors.values() for vector in vectors]

View File

@@ -1,50 +0,0 @@
from __future__ import annotations
import logging
from typing import Sequence
from app.ml.feature_store import FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact, TrainingPipeline
logger = logging.getLogger(__name__)
class Predictor:
def __init__(
self,
pipeline: TrainingPipeline | None = None,
registry: ModelRegistry | None = None,
) -> None:
if isinstance(pipeline, ModelRegistry) and registry is None:
registry = pipeline
pipeline = None
if pipeline is None and registry is None:
raise ValueError("Predictor erfordert TrainingPipeline oder ModelRegistry.")
self._pipeline = pipeline
self._registry = registry
def predict(self, artifact_id: str, entity: FeatureVector) -> str:
artifact = self._get_artifact(artifact_id)
if entity.sensor_id not in artifact.supported_sensors:
raise ValueError(
f"Sensor '{entity.sensor_id}' wird vom Modell '{artifact_id}' nicht unterstützt."
)
return f"{artifact_id}:{entity.sensor_id}:{entity.values}"
def predict_batch(self, artifact_id: str, entities: Sequence[FeatureVector]) -> list[str]:
return [self.predict(artifact_id, entity) for entity in entities]
@staticmethod
def default_artifact(pipeline: TrainingPipeline) -> TrainedArtifact:
artifacts = list(pipeline._artifacts)
if not artifacts:
raise ValueError("Kein trainiertes Modell gefunden.")
return pipeline.export(artifacts[-1])
def _get_artifact(self, artifact_id: str) -> TrainedArtifact:
if self._registry is not None:
return self._registry.load_artifact(artifact_id)
if self._pipeline is not None:
return self._pipeline.export(artifact_id)
raise RuntimeError("Predictor nicht initialisiert.")

View File

@@ -1,3 +0,0 @@
from .model_registry import ModelRegistry
__all__ = ["ModelRegistry"]

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
import logging
from pathlib import Path
from typing import Iterable
from app.ml.training import TrainedArtifact
logger = logging.getLogger(__name__)
class ModelRegistry:
def __init__(self, root: str | Path) -> None:
self._root = Path(root)
self._root.mkdir(parents=True, exist_ok=True)
self._artifacts: dict[str, TrainedArtifact] = {}
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
self._artifacts[artifact.artifact_id] = artifact
self._persist(artifact)
return artifact
def load_artifact(self, artifact_id: str) -> TrainedArtifact:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
return self._artifacts[artifact_id]
def list_models(self) -> Iterable[TrainedArtifact]:
return list(self._artifacts.values())
def _persist(self, artifact: TrainedArtifact) -> None:
target = self._root / f"{artifact.artifact_id}.json"
target.write_text(
f"{artifact.artifact_id}\t{','.join(artifact.supported_sensors)}\n",
encoding="utf-8",
)
logger.info("Modell gespeichert: %s", target)

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Sequence
from app.ml.feature_store import FeatureVector, FeatureStore
logger = logging.getLogger(__name__)
@dataclass
class TrainedArtifact:
artifact_id: str
supported_sensors: tuple[str, ...]
class TrainingPipeline:
def __init__(self, store: FeatureStore) -> None:
self._store = store
self._artifacts: dict[str, TrainedArtifact] = {}
def run(self, artifact_id: str) -> TrainedArtifact:
vectors = self._store.all()
if not vectors:
raise ValueError("FeatureStore enthält keine Trainingsdaten.")
sensors = tuple(sorted({vector.sensor_id for vector in vectors}))
artifact = TrainedArtifact(artifact_id=artifact_id, supported_sensors=sensors)
self._artifacts[artifact_id] = artifact
logger.info("Training abgeschlossen für %s mit %d Sensoren", artifact_id, len(sensors))
return artifact
def export(self, artifact_id: str) -> TrainedArtifact:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.")
return self._artifacts[artifact_id]

View File

@@ -7,28 +7,9 @@ from app.rules.recommender import Rule
class HeatingRule(Rule):
"""Heizungsregel: Nur auf heizungsrelevante Entitäten reagieren.
Triggert bei:
- `climate`-Entitäten direkt
- `sensor` mit `device_class` in {temperature, humidity}
- `binary_sensor` mit `device_class` in {occupancy, presence}
Alle anderen Domains/Device-Klassen bleiben ohne Effekt.
"""
HEATING_SENSOR_CLASSES: frozenset[str] = frozenset({"temperature", "humidity"})
HEATING_PRESENCE_CLASSES: frozenset[str] = frozenset({"occupancy", "presence"})
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
for item in entities:
if item.domain == "climate":
return True
if item.domain == "sensor" and item.device_class in self.HEATING_SENSOR_CLASSES:
return True
if item.domain == "binary_sensor" and item.device_class in self.HEATING_PRESENCE_CLASSES:
return True
return False
domains = {item.domain for item in entities}
return "climate" in domains or "sensor" in domains
def recommendation(self, entities: Sequence[HaEntitySummary]) -> str:
return "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."

View File

@@ -1,37 +0,0 @@
from fastapi import FastAPI
from backend.routes.ml import init_ml_routes
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
from app.ml.feature_store import FeatureStore, FeatureVector
def create_app() -> FastAPI:
application = FastAPI(title="SillyHome Next ML")
init_ml_routes(application)
_seed_default_model(application.state if hasattr(application, "state") else application)
return application
def _app_state(): # noqa: ANN001
return app.state
def _seed_default_model(state) -> None: # noqa: ANN001
registry = getattr(state, "registry", None)
if registry is None:
registry = ModelRegistry(".model_store")
state.registry = registry
if list(registry.list_models()):
return
store = FeatureStore()
store.add(FeatureVector(sensor_id="sensor.front_door", values={"contact": 1.0}))
store.add(FeatureVector(sensor_id="sensor.living_room", values={"temperature": 21.0}))
pipeline = TrainingPipeline(store)
artifact = pipeline.run("default")
registry.register(artifact)
app = create_app()

View File

@@ -1,104 +0,0 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import List, Sequence
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.ml.feature_store import FeatureVector
from app.ml.predictor import Predictor
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ml", tags=["ml"])
class HealthResponse(BaseModel):
status: str
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class PredictRequest(BaseModel):
model_id: str = Field(..., alias="modelId")
sensor_id: str
values: dict
class PredictResponse(BaseModel):
model_id: str
sensor_id: str
prediction: str
class BatchRequest(BaseModel):
requests: Sequence[PredictRequest]
class BatchResponse(BaseModel):
predictions: Sequence[PredictResponse]
class ModelsResponse(BaseModel):
models: List[str]
@router.get("/health", response_model=HealthResponse, status_code=200)
def health() -> HealthResponse:
return HealthResponse(status="ok")
@router.get("/models", response_model=ModelsResponse, status_code=200)
def list_models() -> ModelsResponse:
registry = _require_registry()
models = [artifact.artifact_id for artifact in registry.list_models()]
return ModelsResponse(models=models)
@router.post("/predict", response_model=PredictResponse, status_code=200)
def predict(request: PredictRequest) -> PredictResponse:
registry = _require_registry()
predictor = Predictor(registry=registry)
vector = FeatureVector(sensor_id=request.sensor_id, values=request.values)
try:
prediction = predictor.predict(request.model_id, vector)
except KeyError as exc: # unknown artifact
raise _not_found_error(str(exc)) from exc
return PredictResponse(model_id=request.model_id, sensor_id=request.sensor_id, prediction=prediction)
@router.post("/batch", response_model=BatchResponse, status_code=200)
def predict_batch(request: BatchRequest) -> BatchResponse:
registry = _require_registry()
predictor = Predictor(registry=registry)
responses: List[PredictResponse] = []
for item in request.requests:
vector = FeatureVector(sensor_id=item.sensor_id, values=item.values)
try:
prediction = predictor.predict(item.model_id, vector)
except KeyError as exc:
raise _not_found_error(str(exc)) from exc
responses.append(
PredictResponse(model_id=item.model_id, sensor_id=item.sensor_id, prediction=prediction)
)
return BatchResponse(predictions=responses)
def _require_registry() -> ModelRegistry:
from backend.app import _app_state
state_obj = _app_state()
registry = getattr(state_obj, "registry", None)
if registry is None:
raise RuntimeError("ML registry nicht initialisiert.")
return registry
def init_ml_routes(app) -> None: # noqa: ANN001
registry = ModelRegistry(".model_store")
app.state.registry = registry
app.include_router(router)
logger.info("ML routes registered")

View File

@@ -1,8 +0,0 @@
services:
api:
build: .
ports:
- "8000:8000"
env_file:
- .env
restart: unless-stopped

View File

@@ -1,116 +0,0 @@
# ML-Serving-API
Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHome Next.
## Basis-URL
- Standard: `http://127.0.0.1:8000/ml`
- Health: `/health`
- Modelle: `/models`
- Einzelvorhersage: `/predict`
- Batchvorhersage: `/batch`
Der Standard-Start erfolgt über `uvicorn backend.app:app --reload`, danach steht die API unter `/ml` bereit.
## Endpoints
### `GET /ml/health`
Health-Check der ML-Services.
**Beispielantwort**
```json
{
"status": "ok",
"updated_at": "2026-06-11T12:00:00Z"
}
```
### `GET /ml/models`
Listet alle registrierten Modell-Artefakte auf.
**Beispielantwort**
```json
{
"models": ["default"]
}
```
### `POST /ml/predict`
Einzelne Vorhersage für einen Sensor.
**Request**
```json
{
"modelId": "default",
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0}
}
```
**Antwort**
```json
{
"model_id": "default",
"sensor_id": "sensor.kitchen",
"prediction": "default:sensor.kitchen:{'temperature': 21.0}"
}
```
### `POST /ml/batch`
Batch-Vorhersage für mehrere Sensorwerte.
**Request**
```json
{
"requests": [
{
"modelId": "default",
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0}
},
{
"modelId": "default",
"sensor_id": "sensor.bedroom",
"values": {"temperature": 18.5}
}
]
}
```
**Antwort**
```json
{
"predictions": [
{
"model_id": "default",
"sensor_id": "sensor.kitchen",
"prediction": "default:sensor.kitchen:{'temperature': 21.0}"
},
{
"model_id": "default",
"sensor_id": "sensor.bedroom",
"prediction": "default:sensor.bedroom:{'temperature': 18.5}"
}
]
}
```
## Fehlerfälle
- `400 Bad Request`: Fehlende oder ungültige Felder.
- `404 Not Found`: Modell oder Sensor nicht registriert.
- `500 Internal Server Error`: Registry nicht initialisiert oder unerwarteter Fehler.
## Betrieb
Beim Start wird automatisch ein Default-Artefakt erstellt, falls noch kein Modell registriert ist. Neue Modelle müssen zusätzlich über `ModelRegistry.register(...)` eingetragen werden.
## Verweise
- `app/ml/predictor.py`
- `app/ml/registry/model_registry.py`
- `backend/routes/ml.py`

View File

@@ -1,39 +0,0 @@
# ML Training- und Evaluations-Workflow
Dieser Workflow beschreibt, wie Modelle trainiert, evaluiert und an der Serving-Layer registriert werden.
## 1. Daten sammeln
Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen.
## 2. Modell trainieren
```python
store = FeatureStore()
store.add(FeatureVector(sensor_id="sensor.kitchen", values={"temperature": 21.0}))
pipeline = TrainingPipeline(store)
artifact = pipeline.run("my_artifact")
pipeline.export("my_artifact")
```
`TrainingPipeline.run(...)` erzeugt ein `TrainedArtifact` mit den unterstützten Sensor-IDs.
## 3. Modell evaluieren
```python
evaluator = Evaluator(pipeline)
report = evaluator.evaluate(artifact.artifact_id, predictions)
```
Der Report enthält:
- `artifact_id`
- `sample_size`
- Metriken wie `coverage` und `unknown_rate` mit Default-Schwellenwerten
## 4. Modell registrieren
Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifact)` bereitgestellt werden. Die ML-Serving-API stellt es unter `/ml/predict` und `/ml/batch` zur Verfügung.
## Hinweise
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus.

View File

@@ -7,12 +7,10 @@ dependencies = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.29.0",
"pydantic>=2.6.0",
"requests>=2.31.0",
]
[project.optional-dependencies]
dev = [
"httpx2>=2.3.0",
"pytest>=8.0.0",
"ruff>=0.4.0",
"mypy>=1.9.0",
@@ -27,4 +25,4 @@ strict = true
[tool.ruff]
line-length = 100
target-version = "py311"
target-version = "py311"

View File

@@ -1,4 +1,5 @@
import requests
import json
from pathlib import Path
p = Path('/root/.openclaw/secrets/gitea.env')

View File

@@ -1,63 +1,10 @@
from collections.abc import Sequence
from fastapi.testclient import TestClient
from app.ha.exceptions import HaTimeoutError
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import app
class FakeHaReader(HaReader):
def __init__(self) -> None:
pass
def read_entities(self) -> Sequence[HaEntitySummary]:
return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")]
class TimeoutHaReader(HaReader):
def __init__(self) -> None:
pass
def read_entities(self) -> Sequence[HaEntitySummary]:
raise HaTimeoutError("contains internal details that must not leak")
client = TestClient(app)
def test_openapi_docs_are_available() -> None:
with TestClient(app) as client:
response = client.get("/docs")
response = client.get("/docs")
assert response.status_code == 200
assert "SillyHome Next API" in response.text
def test_entities_returns_reader_data() -> None:
with TestClient(app) as client:
app.state.ha_reader = FakeHaReader()
response = client.get("/v1/entities")
assert response.status_code == 200
assert response.json() == [
{
"entity_id": "sensor.temperature",
"domain": "sensor",
"state_class": None,
"device_class": None,
"unit_of_measurement": None,
}
]
def test_entities_returns_503_without_home_assistant_config() -> None:
with TestClient(app) as client:
if hasattr(app.state, "ha_reader"):
delattr(app.state, "ha_reader")
response = client.get("/v1/entities")
assert response.status_code == 503
def test_entities_maps_ha_errors_without_leaking_details() -> None:
with TestClient(app) as client:
app.state.ha_reader = TimeoutHaReader()
response = client.get("/v1/entities")
assert response.status_code == 504
assert response.json() == {"detail": "Home Assistant request timed out."}

View File

@@ -1,71 +0,0 @@
from __future__ import annotations
from unittest.mock import Mock
import pytest
import requests
from app.ha.client import HaClient, HaClientSettings
from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
def _client_with_response(response: Mock) -> HaClient:
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.get = Mock(return_value=response) # type: ignore[method-assign]
return client
def _response(status_code: int = 200, payload: object | None = None) -> Mock:
response = Mock()
response.status_code = status_code
response.json.return_value = [] if payload is None else payload
if status_code >= 400:
response.raise_for_status.side_effect = requests.HTTPError("upstream failed")
return response
def test_list_entities_returns_home_assistant_payload() -> None:
payload = [{"entity_id": "sensor.temperature", "state": "21"}]
client = _client_with_response(_response(payload=payload))
assert client.list_entities() == payload
def test_list_entities_maps_timeout() -> None:
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.get = Mock(side_effect=requests.Timeout("timed out")) # type: ignore[method-assign]
with pytest.raises(HaTimeoutError):
client.list_entities()
@pytest.mark.parametrize("status_code", [401, 403])
def test_list_entities_maps_auth_errors(status_code: int) -> None:
client = _client_with_response(_response(status_code=status_code))
with pytest.raises(HaAuthError) as exc_info:
client.list_entities()
assert exc_info.value.status_code == status_code
def test_list_entities_maps_http_errors() -> None:
client = _client_with_response(_response(status_code=500))
with pytest.raises(HaHttpError) as exc_info:
client.list_entities()
assert exc_info.value.status_code == 500
def test_list_entities_rejects_invalid_json() -> None:
response = _response()
response.json.side_effect = ValueError("not json")
client = _client_with_response(response)
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()
def test_list_entities_rejects_non_list_payload() -> None:
client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from app.ha.client import HaClient, HaClientSettings
from app.ha.models import HaEntitySummary, HaState
from app.ha.reader import HaReader

View File

@@ -1,33 +0,0 @@
from __future__ import annotations
import pytest
from app.ml.evaluation import Evaluator
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def evaluator_factory() -> Evaluator:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1")
return Evaluator(pipeline)
def test_evaluate_returns_report_with_metrics() -> None:
evaluator = evaluator_factory()
report = evaluator.evaluate("artifact_v1", ["artifact_v1:sensor.kitchen:{'temperature': 21.0}", "artifact_v1:sensor.bedroom:{'temperature': 18.5}"])
assert report.artifact_id == "artifact_v1"
assert report.sample_size == 2
assert {metric.name for metric in report.metrics} == {"coverage", "unknown_rate"}
def test_evaluate_without_training_raises_value_error() -> None:
evaluator = Evaluator(TrainingPipeline(FeatureStore()))
with pytest.raises(ValueError):
evaluator.evaluate("artifact_v1", [])

View File

@@ -1,42 +0,0 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def test_append_and_latest_returns_last_vector() -> None:
store = FeatureStore()
vectors = [_vector("sensor.living_room", 20.0), _vector("sensor.living_room", 21.5)]
for item in vectors:
store.add(item)
assert store.latest("sensor.living_room") == vectors[-1]
def test_latest_returns_none_when_empty() -> None:
store = FeatureStore()
assert store.latest("sensor.living_room") is None
def test_add_batch_appends_all_vectors() -> None:
store = FeatureStore()
vectors = [
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bathroom", 23.5),
]
store.add_batch(vectors)
assert len(store.all()) == 3
assert store.latest("sensor.kitchen").values["temperature"] == 20.0
def test_different_sensors_are_stored_independently() -> None:
store = FeatureStore()
store.add(_vector("sensor.living_room", 21.0))
store.add(_vector("sensor.bedroom", 18.5))
assert store.latest("sensor.living_room").values["temperature"] == 21.0
assert store.latest("sensor.bedroom").values["temperature"] == 18.5

View File

@@ -1,48 +0,0 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.predictor import Predictor
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def predictor() -> Predictor:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1")
return Predictor(pipeline)
def test_predict_returns_expected_format() -> None:
p = predictor()
result = p.predict("artifact_v1", _vector("sensor.kitchen", 21.0))
assert result == "artifact_v1:sensor.kitchen:{'temperature': 21.0}"
def test_predict_rejects_unknown_sensor() -> None:
p = predictor()
with pytest.raises(ValueError):
p.predict("artifact_v1", _vector("sensor.unknown", 10.0))
def test_predict_batch_matches_single_calls() -> None:
p = predictor()
entities = [_vector("sensor.kitchen", 21.0), _vector("sensor.bedroom", 19.0)]
assert p.predict_batch("artifact_v1", entities) == [
p.predict("artifact_v1", item) for item in entities
]
def test_default_artifact_returns_last_registered() -> None:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
pipeline.run("first")
pipeline.run("second")
assert Predictor.default_artifact(pipeline).artifact_id == "second"

View File

@@ -1,48 +0,0 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline, TrainedArtifact
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def store_with_data() -> TrainingPipeline:
store = FeatureStore()
store.add_batch(
[
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bedroom", 18.5),
]
)
return TrainingPipeline(store)
def test_run_returns_trained_artifact() -> None:
pipeline = store_with_data()
artifact = pipeline.run("artifact_v1")
assert artifact.artifact_id == "artifact_v1"
assert artifact.supported_sensors == ("sensor.bedroom", "sensor.kitchen")
def test_run_without_data_raises_value_error() -> None:
pipeline = TrainingPipeline(FeatureStore())
with pytest.raises(ValueError):
pipeline.run("artifact_v1")
def test_export_returns_registered_artifact() -> None:
pipeline = store_with_data()
pipeline.run("artifact_v1")
exported = pipeline.export("artifact_v1")
assert exported == pipeline.export("artifact_v1")
def test_export_missing_artifact_raises_key_error() -> None:
pipeline = store_with_data()
with pytest.raises(KeyError):
pipeline.export("artifact_v1")

View File

@@ -1,33 +0,0 @@
from __future__ import annotations
from app.ml.evaluation import Evaluator, EvalReport, Metric
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def test_end_to_end_training_then_evaluation() -> None:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
artifact = pipeline.run("artifact_v1")
evaluator = Evaluator(pipeline)
predictions = [
"artifact_v1:sensor.kitchen:{'temperature': 21.0}",
"artifact_v1:sensor.bedroom:{'temperature': 18.5}",
]
report = evaluator.evaluate(artifact.artifact_id, predictions)
assert isinstance(report, EvalReport)
assert report.sample_size == len(predictions)
assert any(metric.name == "coverage" for metric in report.metrics)
def test_metric_helpers_are_serializable() -> None:
metric = Metric(name="coverage", value=0.85, threshold=0.8)
assert metric.name == "coverage"
assert metric.value == 0.85
assert metric.threshold == 0.8

View File

@@ -1,64 +1,26 @@
from __future__ import annotations
import pytest
from app.ha.models import HaEntitySummary
from app.rules.heating import HeatingRule
from app.rules.recommender import Recommender
def _entity(entity_id: str, domain: str, device_class: str | None = None) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain=domain, device_class=device_class)
def _sensor(entity_id: str) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain="sensor")
# --- positive cases --------------------------------------------------------
@pytest.mark.parametrize(
"entity",
[
_entity("climate.living_room", "climate"),
_entity("sensor.temperature_living", "sensor", "temperature"),
_entity("sensor.humidity_bathroom", "sensor", "humidity"),
_entity("binary_sensor.living_room_occupancy", "binary_sensor", "occupancy"),
_entity("binary_sensor.entrance_presence", "binary_sensor", "presence"),
],
ids=lambda e: e.entity_id,
)
def test_heating_rule_triggers_for_relevant_entities(entity: HaEntitySummary) -> None:
def _climate(entity_id: str) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain="climate")
def test_heating_rule_triggers() -> None:
rule = HeatingRule()
assert rule.matches([entity]) is True
assert rule.matches([_climate("climate.living_room")])
assert rule.matches([_sensor("sensor.temperature_living")])
# --- negative cases -------------------------------------------------------
@pytest.mark.parametrize(
"entity",
[
_entity("sensor.power_consumption", "sensor", "power"),
_entity("sensor.door", "sensor", "door"),
_entity("sensor.energy", "sensor", "energy"),
_entity("binary_sensor.door_window", "binary_sensor", "door"),
_entity("binary_sensor.motion", "binary_sensor", "motion"),
_entity("light.living_room", "light"),
_entity("switch.plug", "switch"),
_entity("sensor.some_random", "sensor"),
_entity("binary_sensor.some_binary", "binary_sensor"),
],
ids=lambda e: e.entity_id,
)
def test_heating_rule_ignores_non_heating_entities(entity: HaEntitySummary) -> None:
rule = HeatingRule()
assert rule.matches([entity]) is False
def test_heating_rule_mixed_list_returns_true() -> None:
rule = HeatingRule()
entities = [
_entity("sensor.power", "sensor", "power"),
_entity("climate.living_room", "climate"),
_entity("light.ceiling", "light"),
]
assert rule.matches(entities) is True
def test_heating_rule_recommendation_is_stable() -> None:
rule = HeatingRule()
expected = "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."
assert rule.recommendation([_entity("climate.living_room", "climate")]) == expected
def test_recommender_uses_rule() -> None:
recommender = Recommender(rules=[HeatingRule()])
assert recommender.run([_climate("climate.living_room")]) == [
"Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."
]

View File

@@ -1,10 +1,10 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health_returns_ok() -> None:
with TestClient(app) as client:
response = client.get("/health")
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}