Compare commits
8 Commits
feature/ha
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
| f4f23796d5 | |||
| 6540d62ff7 | |||
| 6b1e2ad0dc | |||
| 6bba8f5947 | |||
| 009d4b68cb | |||
| 9016dbad18 | |||
| d81ebf399c | |||
| bdf33458f0 |
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Home Assistant Zugriff
|
||||||
|
SILLYHOME_HA_URL=http://localhost:8123
|
||||||
|
SILLYHOME_HA_TOKEN=dein_long_lived_access_token
|
||||||
31
README.md
31
README.md
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
19
app/api/v1/entities.py
Normal file
19
app/api/v1/entities.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Sequence
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.ha.models import HaEntitySummary
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/v1", tags=["entities"])
|
||||||
|
|
||||||
|
|
||||||
|
@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() -> Sequence[HaEntitySummary]:
|
||||||
|
raise NotImplementedError("Integration mit dem HA-Client folgt in separatem Issue.")
|
||||||
1
app/ha/__init__.py
Normal file
1
app/ha/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# sillyhome-next.ha
|
||||||
38
app/main.py
38
app/main.py
@@ -1,12 +1,46 @@
|
|||||||
from fastapi import FastAPI
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Depends
|
||||||
|
|
||||||
|
from app.api.v1.entities import router as entities_router
|
||||||
|
from app.ha.client import HaClient, HaClientSettings
|
||||||
|
from app.ha.reader import HaReader
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
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)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
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.1.0",
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -14,4 +48,4 @@ def health() -> dict[str, str]:
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root() -> dict[str, str]:
|
def root() -> dict[str, str]:
|
||||||
return {"service": "sillyhome-next", "docs": "/docs"}
|
return {"service": "sillyhome-next", "docs": "/docs"}
|
||||||
1
app/rules/__init__.py
Normal file
1
app/rules/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# sillyhome-next.rules
|
||||||
15
app/rules/heating.py
Normal file
15
app/rules/heating.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from app.ha.models import HaEntitySummary
|
||||||
|
from app.rules.recommender import Rule
|
||||||
|
|
||||||
|
|
||||||
|
class HeatingRule(Rule):
|
||||||
|
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
|
||||||
|
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."
|
||||||
25
app/rules/recommender.py
Normal file
25
app/rules/recommender.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from app.ha.models import HaEntitySummary
|
||||||
|
|
||||||
|
|
||||||
|
class Rule:
|
||||||
|
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def recommendation(self, entities: Sequence[HaEntitySummary]) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class Recommender:
|
||||||
|
def __init__(self, rules: Sequence[Rule]) -> None:
|
||||||
|
self._rules = rules
|
||||||
|
|
||||||
|
def run(self, entities: Sequence[HaEntitySummary]) -> list[str]:
|
||||||
|
results: list[str] = []
|
||||||
|
for rule in self._rules:
|
||||||
|
if rule.matches(entities):
|
||||||
|
results.append(rule.recommendation(entities))
|
||||||
|
return results
|
||||||
@@ -25,4 +25,4 @@ strict = true
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
10
tests/api/test_entities.py
Normal file
10
tests/api/test_entities.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_docs_are_available() -> None:
|
||||||
|
response = client.get("/docs")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "SillyHome Next API" in response.text
|
||||||
26
tests/rules/test_heating.py
Normal file
26
tests/rules/test_heating.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.ha.models import HaEntitySummary
|
||||||
|
from app.rules.heating import HeatingRule
|
||||||
|
from app.rules.recommender import Recommender
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor(entity_id: str) -> HaEntitySummary:
|
||||||
|
return HaEntitySummary(entity_id=entity_id, domain="sensor")
|
||||||
|
|
||||||
|
|
||||||
|
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([_climate("climate.living_room")])
|
||||||
|
assert rule.matches([_sensor("sensor.temperature_living")])
|
||||||
|
|
||||||
|
|
||||||
|
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."
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user