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
16 changed files with 76 additions and 126 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Home Assistant Zugriff
SILLYHOME_HA_URL=http://localhost:8123
SILLYHOME_HA_TOKEN=dein_long_lived_access_token

View File

@@ -12,3 +12,34 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Automationen vorschlagen und direkt generieren - Automationen vorschlagen und direkt generieren
- Lokal-first ohne Cloudpflicht - Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert - Erweiterbar, testbar, dokumentiert
## Quickstart (lokaler Betrieb)
1. **Voraussetzungen**
- Python 3.11+
- Home Assistant mit REST-API erreichbar
- `pip install -e .[dev]`
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. **Server starten**
```
uvicorn app.main:app --reload
```
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)
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,12 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from typing import List, Sequence
from fastapi import APIRouter, Depends from fastapi import APIRouter
from app.dependencies import get_ha_reader
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
router = APIRouter(prefix="/v1", tags=["entities"]) router = APIRouter(prefix="/v1", tags=["entities"])
@@ -15,7 +13,7 @@ router = APIRouter(prefix="/v1", tags=["entities"])
"/entities", "/entities",
summary="Home-Assistant-Entities auflisten", summary="Home-Assistant-Entities auflisten",
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.", description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
response_model=list[HaEntitySummary], response_model=List[HaEntitySummary],
) )
def list_entities(reader: HaReader = Depends(get_ha_reader)) -> Sequence[HaEntitySummary]: def list_entities() -> Sequence[HaEntitySummary]:
return reader.read_entities() 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,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

@@ -2,7 +2,6 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import requests import requests
@@ -25,14 +24,10 @@ class HaClient:
"Content-Type": "application/json", "Content-Type": "application/json",
}) })
def list_entities(self) -> list[dict[str, Any]]: def list_entities(self) -> list[dict[str, object]]:
response = self._session.get( response = self._session.get(
f"{self._settings.url}/api/states", f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds, timeout=self._settings.timeout_seconds,
) )
response.raise_for_status() response.raise_for_status()
payload = response.json() return response.json()
if not isinstance(payload, list):
msg = "Home Assistant states response must be a list."
raise TypeError(msg)
return payload

View File

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

View File

@@ -1,25 +1,19 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager 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.api.v1.entities import router as entities_router
from app.config import load_settings
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader from app.ha.reader import HaReader
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI):
settings = load_settings() settings = HaClientSettings(
app.state.settings = settings url=app.state.settings.ha_url,
if settings.ha_configured: token=app.state.settings.ha_token,
client = HaClient(
settings=HaClientSettings(
url=settings.ha_url or "",
token=settings.ha_token or "",
)
) )
client = HaClient(settings=settings)
app.state.ha_reader = HaReader(client=client) app.state.ha_reader = HaReader(client=client)
yield yield
@@ -32,7 +26,19 @@ app = FastAPI(
) )
app.include_router(entities_router) class Settings:
ha_url: str
ha_token: str
app.state.settings = Settings()
def get_ha_reader() -> HaReader:
return app.state.ha_reader
app.include_router(entities_router, dependencies=[Depends(get_ha_reader)])
@app.get("/health") @app.get("/health")

View File

@@ -7,12 +7,10 @@ dependencies = [
"fastapi>=0.110.0", "fastapi>=0.110.0",
"uvicorn[standard]>=0.29.0", "uvicorn[standard]>=0.29.0",
"pydantic>=2.6.0", "pydantic>=2.6.0",
"requests>=2.31.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"httpx2>=2.3.0",
"pytest>=8.0.0", "pytest>=8.0.0",
"ruff>=0.4.0", "ruff>=0.4.0",
"mypy>=1.9.0", "mypy>=1.9.0",

View File

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

View File

@@ -1,46 +1,10 @@
from collections.abc import Sequence
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import app from app.main import app
client = TestClient(app)
class FakeHaReader(HaReader):
def __init__(self) -> None:
pass
def read_entities(self) -> Sequence[HaEntitySummary]:
return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")]
def test_openapi_docs_are_available() -> None: 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 response.status_code == 200
assert "SillyHome Next API" in response.text 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

View File

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

View File

@@ -1,10 +1,10 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
client = TestClient(app)
def test_health_returns_ok() -> None: 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.status_code == 200
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}