Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d9faca720 | ||
|
|
08ff519216 | ||
|
|
73ea0441fb | ||
|
|
debc2a0bc8 | ||
|
|
17b034e8f6 |
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
# Generate token: openssl rand -hex 32
|
||||||
|
API_TOKEN=CHANGE_ME_LONG_RANDOM_TOKEN
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8090
|
||||||
|
|
||||||
|
# MQTT – same host/port as your openHAB Mosquitto
|
||||||
|
MQTT_HOST=192.168.178.60
|
||||||
|
MQTT_PORT=1884
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
MQTT_TLS=false
|
||||||
|
MQTT_PREFIX=openhab/ai_bridge
|
||||||
|
|
||||||
|
DB_PATH=/data/bridge.db
|
||||||
|
MAX_HISTORY_MESSAGES=16
|
||||||
|
SESSION_TTL_SECONDS=7200
|
||||||
|
MAX_SESSIONS=20
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Pi speaker (ALSA)
|
||||||
|
ALSA_PLAYBACK_DEVICE=default
|
||||||
|
AUDIO_GID=29
|
||||||
|
|
||||||
|
# Free Edge TTS (German)
|
||||||
|
TTS_VOICE=de-DE-ConradNeural
|
||||||
|
TTS_RATE=+0%
|
||||||
|
TTS_VOLUME=+0%
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
alsa-utils libasound2 ffmpeg libatomic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin bridge
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
COPY app ./app
|
||||||
|
RUN mkdir -p /data /data/audio && chown -R bridge:bridge /app /data
|
||||||
|
USER bridge
|
||||||
|
EXPOSE 8090
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# openHAB AI Bridge – Simple (Text + Claude + Edge TTS)
|
||||||
|
|
||||||
|
Kostenlose KI-Anbindung über **Ask Claude** (iOS Shortcut), ohne API-Key.
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
```
|
||||||
|
openHAB Textfeld
|
||||||
|
→ Bridge (Session + Kontext in SQLite)
|
||||||
|
→ MQTT notify
|
||||||
|
→ openHAB Cloud Push ans iPhone
|
||||||
|
→ Shortcut: Ask Claude
|
||||||
|
→ Antwort zurück an Bridge
|
||||||
|
→ Edge TTS → Lautsprecher am Pi
|
||||||
|
```
|
||||||
|
|
||||||
|
Rückfragen nutzen denselben Session-Kontext (Standard: 2 Stunden).
|
||||||
|
|
||||||
|
## Schnellstart (Raspberry Pi)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/openhab-ai-bridge-simple
|
||||||
|
cp .env.example .env
|
||||||
|
openssl rand -hex 32 # → in .env als API_TOKEN eintragen
|
||||||
|
getent group audio | cut -d: -f3 # → AUDIO_GID setzen
|
||||||
|
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
curl http://127.0.0.1:8090/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## openHAB
|
||||||
|
|
||||||
|
1. Thing + Items aus `openhab/` übernehmen (Broker-UID anpassen).
|
||||||
|
2. JS-Regel aus `openhab/ai_bridge.rules.js` (Token + IP eintragen).
|
||||||
|
3. Regel „AI Bridge Notify → Push“ sendet die Cloud-Notification.
|
||||||
|
|
||||||
|
## iOS Shortcut
|
||||||
|
|
||||||
|
1. Trigger: openHAB-Notification mit Text `KI_DIALOG`
|
||||||
|
2. GET `http://PI:8090/api/v1/active` (Header: `Authorization: Bearer TOKEN`)
|
||||||
|
3. GET `/api/v1/sessions/{session_id}/context`
|
||||||
|
4. Prompt aus Messages bauen → **Ask Claude**
|
||||||
|
5. POST `/api/v1/sessions/{session_id}/answer` `{"content":"..."}`
|
||||||
|
6. POST `/api/v1/audio/speak` `{"text":"..."}` (Pi spricht)
|
||||||
|
|
||||||
|
## API (alle außer /health brauchen Bearer-Token)
|
||||||
|
|
||||||
|
| Methode | Pfad | Zweck |
|
||||||
|
|---------|------|--------|
|
||||||
|
| POST | /api/v1/dialog | Frage aus openHAB |
|
||||||
|
| GET | /api/v1/active | Aktive Session |
|
||||||
|
| GET | /api/v1/sessions/{id}/context | Gesprächsverlauf |
|
||||||
|
| POST | /api/v1/sessions/{id}/answer | Claude-Antwort speichern |
|
||||||
|
| POST | /api/v1/audio/speak | Edge TTS abspielen |
|
||||||
|
| POST | /api/v1/sessions/{id}/end | Session beenden |
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Nur LAN, Port 8090 nicht nach außen freigeben
|
||||||
|
- Langer zufälliger API_TOKEN
|
||||||
|
- Docker: non-root, read-only root, dropped capabilities
|
||||||
+498
@@ -0,0 +1,498 @@
|
|||||||
|
"""
|
||||||
|
openHAB AI Bridge (simple) – text dialog + session context + Edge TTS.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
openHAB text → POST /api/v1/dialog → SQLite session
|
||||||
|
→ MQTT notify → openHAB Cloud push → iOS Shortcut
|
||||||
|
→ Ask Claude → POST /api/v1/sessions/{id}/answer
|
||||||
|
→ POST /api/v1/audio/speak → Pi speaker (Edge TTS)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Header, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("openhab-ai-bridge")
|
||||||
|
|
||||||
|
HOST = os.getenv("HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.getenv("PORT", "8090"))
|
||||||
|
API_TOKEN = os.environ["API_TOKEN"]
|
||||||
|
DB_PATH = os.getenv("DB_PATH", "/data/bridge.db")
|
||||||
|
|
||||||
|
MAX_HISTORY = int(os.getenv("MAX_HISTORY_MESSAGES", "16"))
|
||||||
|
SESSION_TTL = int(os.getenv("SESSION_TTL_SECONDS", "7200"))
|
||||||
|
MAX_SESSIONS = int(os.getenv("MAX_SESSIONS", "20"))
|
||||||
|
|
||||||
|
MQTT_HOST = os.getenv("MQTT_HOST", "192.168.178.60")
|
||||||
|
MQTT_PORT = int(os.getenv("MQTT_PORT", "1884"))
|
||||||
|
MQTT_PREFIX = os.getenv("MQTT_PREFIX", "openhab/ai_bridge").strip("/")
|
||||||
|
MQTT_USER = os.getenv("MQTT_USERNAME", "")
|
||||||
|
MQTT_PASS = os.getenv("MQTT_PASSWORD", "")
|
||||||
|
MQTT_TLS = os.getenv("MQTT_TLS", "false").lower() == "true"
|
||||||
|
|
||||||
|
ALSA_PLAYBACK = os.getenv("ALSA_PLAYBACK_DEVICE", "default")
|
||||||
|
TTS_VOICE = os.getenv("TTS_VOICE", "de-DE-ConradNeural")
|
||||||
|
TTS_RATE = os.getenv("TTS_RATE", "+0%")
|
||||||
|
TTS_VOLUME = os.getenv("TTS_VOLUME", "+0%")
|
||||||
|
|
||||||
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
app = FastAPI(title="openHAB AI Bridge", version="3.0.0-simple")
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def db():
|
||||||
|
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
c.execute("PRAGMA journal_mode=WAL")
|
||||||
|
c.execute("PRAGMA busy_timeout=5000")
|
||||||
|
yield c
|
||||||
|
c.commit()
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
with db() as c:
|
||||||
|
c.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sessions(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_response TEXT NOT NULL DEFAULT '')"""
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS messages(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL)"""
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_msg ON messages(session_id, id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_pub(topic: str, payload: str, retain: bool = False):
|
||||||
|
cl = mqtt.Client(
|
||||||
|
mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=f"openhab-ai-bridge-{uuid.uuid4().hex[:8]}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if MQTT_USER:
|
||||||
|
cl.username_pw_set(MQTT_USER, MQTT_PASS)
|
||||||
|
if MQTT_TLS:
|
||||||
|
cl.tls_set()
|
||||||
|
cl.connect(MQTT_HOST, MQTT_PORT, 10)
|
||||||
|
info = cl.publish(topic, payload, qos=1, retain=retain)
|
||||||
|
info.wait_for_publish(5)
|
||||||
|
cl.disconnect()
|
||||||
|
except Exception as e:
|
||||||
|
log.error("MQTT publish failed topic=%s error=%s", topic, e)
|
||||||
|
|
||||||
|
|
||||||
|
def auth(authorization: str | None = Header(default=None)):
|
||||||
|
if authorization != f"Bearer {API_TOKEN}":
|
||||||
|
raise HTTPException(401, "Unauthorized")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
cutoff = time.time() - SESSION_TTL
|
||||||
|
with db() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT id, updated_at FROM sessions WHERE status NOT IN ('ended','expired')"
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
if datetime.fromisoformat(r["updated_at"]).timestamp() < cutoff:
|
||||||
|
c.execute(
|
||||||
|
"UPDATE sessions SET status='expired', updated_at=? WHERE id=?",
|
||||||
|
(now(), r["id"]),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
c.execute(
|
||||||
|
"""DELETE FROM messages WHERE session_id IN
|
||||||
|
(SELECT id FROM sessions WHERE status IN ('ended','expired')
|
||||||
|
AND updated_at < datetime('now','-7 days'))"""
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"""DELETE FROM sessions WHERE status IN ('ended','expired')
|
||||||
|
AND updated_at < datetime('now','-7 days')"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def session_dict(r) -> dict:
|
||||||
|
return {
|
||||||
|
"session_id": r["id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"prompt": r["prompt"],
|
||||||
|
"status": r["status"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
"last_response": r["last_response"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def publish_active_session(sid: str | None):
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/active_session", sid or "", True)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionCreate(BaseModel):
|
||||||
|
title: str = Field(default="KI-Anfrage", max_length=200)
|
||||||
|
prompt: str = Field(min_length=1, max_length=8000)
|
||||||
|
notify_text: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class Answer(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=12000)
|
||||||
|
|
||||||
|
|
||||||
|
class ContinueReq(BaseModel):
|
||||||
|
content: str = Field(min_length=1, max_length=8000)
|
||||||
|
|
||||||
|
|
||||||
|
class DialogReq(BaseModel):
|
||||||
|
text: str = Field(min_length=1, max_length=8000)
|
||||||
|
title: str = Field(default="openHAB Dialog", max_length=200)
|
||||||
|
notify_text: str | None = Field(default="KI_DIALOG", max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakReq(BaseModel):
|
||||||
|
text: str = Field(min_length=1, max_length=12000)
|
||||||
|
voice: str | None = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup():
|
||||||
|
init_db()
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/availability", "ONLINE", True)
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/status", "IDLE", True)
|
||||||
|
log.info(
|
||||||
|
"started port=%s mqtt=%s:%s prefix=%s",
|
||||||
|
PORT,
|
||||||
|
MQTT_HOST,
|
||||||
|
MQTT_PORT,
|
||||||
|
MQTT_PREFIX,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
def shutdown():
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/availability", "OFFLINE", True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "service": "openhab-ai-bridge", "version": "3.0.0-simple"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/pending")
|
||||||
|
def pending(_: None = Depends(auth)):
|
||||||
|
cleanup()
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute(
|
||||||
|
"SELECT * FROM sessions WHERE status='pending' ORDER BY created_at LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return {"pending": bool(r), **(session_dict(r) if r else {})}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/active")
|
||||||
|
def active(_: None = Depends(auth)):
|
||||||
|
cleanup()
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute(
|
||||||
|
"""SELECT * FROM sessions
|
||||||
|
WHERE status IN ('pending','processing','active')
|
||||||
|
ORDER BY updated_at DESC LIMIT 1"""
|
||||||
|
).fetchone()
|
||||||
|
return {"active": bool(r), **(session_dict(r) if r else {})}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session(title: str, prompt: str, notify_text: str | None, source: str) -> str:
|
||||||
|
cleanup()
|
||||||
|
with db() as c:
|
||||||
|
n = c.execute(
|
||||||
|
"SELECT COUNT(*) FROM sessions WHERE status IN ('pending','processing','active')"
|
||||||
|
).fetchone()[0]
|
||||||
|
if n >= MAX_SESSIONS:
|
||||||
|
raise HTTPException(429, "Too many active sessions")
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
ts = now()
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO sessions VALUES(?,?,?,?,?,?,?)",
|
||||||
|
(sid, title, prompt, "pending", ts, ts, ""),
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO messages(session_id, role, content, created_at) VALUES(?,?,?,?)",
|
||||||
|
(sid, "user", prompt, ts),
|
||||||
|
)
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/status", "PENDING", True)
|
||||||
|
publish_active_session(sid)
|
||||||
|
mqtt_pub(
|
||||||
|
f"{MQTT_PREFIX}/session/new",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"session_id": sid,
|
||||||
|
"title": title,
|
||||||
|
"prompt": prompt,
|
||||||
|
"notify_text": notify_text,
|
||||||
|
"source": source,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Notify topic for openHAB → Cloud Push
|
||||||
|
mqtt_pub(
|
||||||
|
f"{MQTT_PREFIX}/notify",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"session_id": sid,
|
||||||
|
"kind": "dialog",
|
||||||
|
"text": prompt[:200],
|
||||||
|
"notify_text": notify_text or "KI_DIALOG",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
log.info("session created id=%s source=%s title=%r", sid, source, title)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/sessions")
|
||||||
|
def create(req: SessionCreate, _: None = Depends(auth)):
|
||||||
|
sid = _create_session(req.title, req.prompt, req.notify_text, "api")
|
||||||
|
return {
|
||||||
|
"session_id": sid,
|
||||||
|
"status": "pending",
|
||||||
|
"title": req.title,
|
||||||
|
"prompt": req.prompt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/sessions/{sid}")
|
||||||
|
def get_session(sid: str, _: None = Depends(auth)):
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT * FROM sessions WHERE id=?", (sid,)).fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
return session_dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/sessions/{sid}/context")
|
||||||
|
def context(sid: str, _: None = Depends(auth)):
|
||||||
|
with db() as c:
|
||||||
|
s = c.execute("SELECT * FROM sessions WHERE id=?", (sid,)).fetchone()
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
m = c.execute(
|
||||||
|
"SELECT role, content, created_at FROM messages "
|
||||||
|
"WHERE session_id=? ORDER BY id DESC LIMIT ?",
|
||||||
|
(sid, MAX_HISTORY),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"session_id": sid,
|
||||||
|
"title": s["title"],
|
||||||
|
"status": s["status"],
|
||||||
|
"messages": [dict(x) for x in reversed(m)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/sessions/{sid}/answer")
|
||||||
|
def answer(sid: str, req: Answer, _: None = Depends(auth)):
|
||||||
|
with db() as c:
|
||||||
|
s = c.execute("SELECT id FROM sessions WHERE id=?", (sid,)).fetchone()
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
ts = now()
|
||||||
|
c.execute(
|
||||||
|
"UPDATE sessions SET status='active', updated_at=?, last_response=? WHERE id=?",
|
||||||
|
(ts, req.content, sid),
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO messages(session_id, role, content, created_at) VALUES(?,?,?,?)",
|
||||||
|
(sid, "assistant", req.content, ts),
|
||||||
|
)
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/last_response", req.content, True)
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/status", "ACTIVE", True)
|
||||||
|
publish_active_session(sid)
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/session/{sid}/answer", req.content)
|
||||||
|
log.info("answer id=%s chars=%d", sid, len(req.content))
|
||||||
|
return {"ok": True, "session_id": sid, "status": "active"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/sessions/{sid}/continue")
|
||||||
|
def cont(sid: str, req: ContinueReq, _: None = Depends(auth)):
|
||||||
|
with db() as c:
|
||||||
|
s = c.execute("SELECT status FROM sessions WHERE id=?", (sid,)).fetchone()
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
if s["status"] in ("ended", "expired"):
|
||||||
|
raise HTTPException(409, "Session ended")
|
||||||
|
ts = now()
|
||||||
|
c.execute(
|
||||||
|
"UPDATE sessions SET status='processing', updated_at=? WHERE id=?",
|
||||||
|
(ts, sid),
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO messages(session_id, role, content, created_at) VALUES(?,?,?,?)",
|
||||||
|
(sid, "user", req.content, ts),
|
||||||
|
)
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/status", "PROCESSING", True)
|
||||||
|
publish_active_session(sid)
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/session/{sid}/user", req.content)
|
||||||
|
mqtt_pub(
|
||||||
|
f"{MQTT_PREFIX}/notify",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"session_id": sid,
|
||||||
|
"kind": "dialog",
|
||||||
|
"text": req.content[:200],
|
||||||
|
"notify_text": "KI_DIALOG",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"ok": True, "session_id": sid, "status": "processing"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/dialog")
|
||||||
|
def dialog(req: DialogReq, _: None = Depends(auth)):
|
||||||
|
"""
|
||||||
|
openHAB entry point.
|
||||||
|
Reuses active session if present, otherwise creates a new one.
|
||||||
|
Publishes MQTT notify so openHAB can trigger the Cloud push.
|
||||||
|
"""
|
||||||
|
cleanup()
|
||||||
|
with db() as c:
|
||||||
|
s = c.execute(
|
||||||
|
"""SELECT * FROM sessions
|
||||||
|
WHERE status IN ('active','processing','pending')
|
||||||
|
ORDER BY updated_at DESC LIMIT 1"""
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if s:
|
||||||
|
sid = s["id"]
|
||||||
|
cont(sid, ContinueReq(content=req.text))
|
||||||
|
mqtt_pub(
|
||||||
|
f"{MQTT_PREFIX}/dialog/input",
|
||||||
|
json.dumps({"session_id": sid, "text": req.text}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return {"session_id": sid, "status": "processing", "reused": True}
|
||||||
|
|
||||||
|
sid = _create_session(req.title, req.text, req.notify_text, "openhab")
|
||||||
|
mqtt_pub(
|
||||||
|
f"{MQTT_PREFIX}/dialog/input",
|
||||||
|
json.dumps({"session_id": sid, "text": req.text}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return {"session_id": sid, "status": "pending", "reused": False}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/sessions/{sid}/end")
|
||||||
|
def end(sid: str, _: None = Depends(auth)):
|
||||||
|
with db() as c:
|
||||||
|
if not c.execute("SELECT id FROM sessions WHERE id=?", (sid,)).fetchone():
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
c.execute(
|
||||||
|
"UPDATE sessions SET status='ended', updated_at=? WHERE id=?",
|
||||||
|
(now(), sid),
|
||||||
|
)
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/status", "IDLE", True)
|
||||||
|
publish_active_session("")
|
||||||
|
mqtt_pub(f"{MQTT_PREFIX}/session/{sid}/ended", "ENDED")
|
||||||
|
return {"ok": True, "session_id": sid, "status": "ended"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/audio/speak")
|
||||||
|
async def speak(req: SpeakReq, _: None = Depends(auth)):
|
||||||
|
"""Edge TTS (free) → WAV → physical Pi speaker via ALSA."""
|
||||||
|
outdir = Path("/data/audio")
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ident = uuid.uuid4().hex
|
||||||
|
mp3 = outdir / f"{ident}.mp3"
|
||||||
|
wav = outdir / f"{ident}.wav"
|
||||||
|
voice = req.voice or TTS_VOICE
|
||||||
|
try:
|
||||||
|
import edge_tts
|
||||||
|
|
||||||
|
communicate = edge_tts.Communicate(
|
||||||
|
req.text, voice, rate=TTS_RATE, volume=TTS_VOLUME
|
||||||
|
)
|
||||||
|
await communicate.save(str(mp3))
|
||||||
|
p = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
str(mp3),
|
||||||
|
"-ar",
|
||||||
|
"48000",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
str(wav),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if p.returncode:
|
||||||
|
raise HTTPException(500, p.stderr[-1000:] or "TTS conversion failed")
|
||||||
|
p = subprocess.run(
|
||||||
|
["aplay", "-D", ALSA_PLAYBACK, str(wav)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
if p.returncode:
|
||||||
|
raise HTTPException(500, p.stderr[-1000:] or "ALSA playback failed")
|
||||||
|
return {"ok": True, "voice": voice, "speaker": "pi-alsa"}
|
||||||
|
finally:
|
||||||
|
for f in (mp3, wav):
|
||||||
|
try:
|
||||||
|
f.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/audio/devices")
|
||||||
|
def audio_devices(_: None = Depends(auth)):
|
||||||
|
out = {}
|
||||||
|
for k, cmd in {
|
||||||
|
"playback": ["aplay", "-l"],
|
||||||
|
"capture": ["arecord", "-l"],
|
||||||
|
}.items():
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||||
|
out[k] = {
|
||||||
|
"returncode": p.returncode,
|
||||||
|
"stdout": p.stdout,
|
||||||
|
"stderr": p.stderr,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
out[k] = {"error": str(e)}
|
||||||
|
return out
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
openhab-ai-bridge:
|
||||||
|
build: .
|
||||||
|
container_name: openhab-ai-bridge
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
devices:
|
||||||
|
- /dev/snd:/dev/snd
|
||||||
|
group_add:
|
||||||
|
- "${AUDIO_GID:-29}"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=32m,noexec,nosuid,nodev
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health',timeout=2).read()",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Setup – Simple Bridge
|
||||||
|
|
||||||
|
## 1. Raspberry Pi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~
|
||||||
|
# Projekt kopieren (oder aus dem ZIP)
|
||||||
|
cp -r openhab-ai-bridge-simple ~/
|
||||||
|
cd ~/openhab-ai-bridge-simple
|
||||||
|
cp .env.example .env
|
||||||
|
openssl rand -hex 32
|
||||||
|
nano .env # API_TOKEN eintragen, MQTT_HOST prüfen
|
||||||
|
```
|
||||||
|
|
||||||
|
Audio-Gruppe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
getent group audio | cut -d: -f3
|
||||||
|
# Wert in AUDIO_GID in .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build && docker compose up -d
|
||||||
|
docker compose logs -f
|
||||||
|
curl http://127.0.0.1:8090/health
|
||||||
|
```
|
||||||
|
|
||||||
|
TTS-Test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8090/api/v1/audio/speak \
|
||||||
|
-H "Authorization: Bearer DEIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text":"Hallo, das ist ein Test vom Raspberry Pi."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. openHAB
|
||||||
|
|
||||||
|
### Thing
|
||||||
|
|
||||||
|
Datei `openhab/ai_bridge.things` nach `$OPENHAB_CONF/things/` kopieren
|
||||||
|
oder im Main UI manuell anlegen (Broker-UID: `mqtt:broker:72a3b87ef9`).
|
||||||
|
|
||||||
|
### Items
|
||||||
|
|
||||||
|
Datei `openhab/ai_bridge.items` nach `$OPENHAB_CONF/items/` kopieren
|
||||||
|
oder Items über die API/MCP anlegen und manuell mit den Channels verlinken.
|
||||||
|
|
||||||
|
### JS-Regel (Dialog → Bridge)
|
||||||
|
|
||||||
|
- Add-on **JavaScript Scripting** installieren
|
||||||
|
- Inhalt von `openhab/ai_bridge.rules.js` als Script anlegen
|
||||||
|
- `BRIDGE_URL` und `BRIDGE_TOKEN` anpassen
|
||||||
|
|
||||||
|
### Push-Regel
|
||||||
|
|
||||||
|
Wenn Item `AI_Bridge_Notify` sich ändert → openHAB Cloud Notification
|
||||||
|
mit Text z. B. `KI_DIALOG <session_id>` an dein iPhone senden.
|
||||||
|
|
||||||
|
Die Notification muss die Automation auf dem iPhone auslösen.
|
||||||
|
|
||||||
|
## 3. iOS Shortcut
|
||||||
|
|
||||||
|
1. Automation: „Wenn Benachrichtigung von openHAB empfangen“ und Text enthält `KI_DIALOG`
|
||||||
|
2. HTTP GET `http://PI-IP:8090/api/v1/active`
|
||||||
|
Header: `Authorization: Bearer TOKEN`
|
||||||
|
3. Session-ID aus der Antwort nehmen
|
||||||
|
4. HTTP GET `.../api/v1/sessions/{id}/context`
|
||||||
|
5. Messages zu einem Prompt zusammensetzen
|
||||||
|
6. Aktion **Ask Claude**
|
||||||
|
7. HTTP POST `.../api/v1/sessions/{id}/answer`
|
||||||
|
Body: `{"content":"<Claude-Antwort>"}`
|
||||||
|
8. HTTP POST `.../api/v1/audio/speak`
|
||||||
|
Body: `{"text":"<Claude-Antwort>"}`
|
||||||
|
|
||||||
|
Optional: Button „Gespräch beenden“ → POST `.../sessions/{id}/end`
|
||||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
Group gAI_Bridge "AI Bridge"
|
||||||
|
|
||||||
|
String AI_Bridge_Availability "AI Bridge Online [%s]" (gAI_Bridge) {channel="mqtt:topic:ai_bridge:availability"}
|
||||||
|
String AI_Bridge_Status "AI Bridge Status [%s]" (gAI_Bridge) {channel="mqtt:topic:ai_bridge:status"}
|
||||||
|
String AI_Bridge_LastResponse "KI Antwort [%s]" (gAI_Bridge) {channel="mqtt:topic:ai_bridge:last_response"}
|
||||||
|
String AI_Bridge_ActiveSession "Aktive Session [%s]" (gAI_Bridge) {channel="mqtt:topic:ai_bridge:active_session"}
|
||||||
|
String AI_Bridge_Notify "AI Notify [%s]" (gAI_Bridge) {channel="mqtt:topic:ai_bridge:notify"}
|
||||||
|
String AI_Bridge_DialogInput "Dialog Input [%s]" (gAI_Bridge) {channel="mqtt:topic:ai_bridge:dialog_input"}
|
||||||
|
|
||||||
|
// Textfeld in der UI – Befehl löst die JS-Regel aus
|
||||||
|
String AI_Bridge_DialogCommand "KI fragen" (gAI_Bridge)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// openHAB JS Scripting – Dialog an die Bridge senden
|
||||||
|
// JavaScript Scripting Add-on muss installiert/aktiviert sein.
|
||||||
|
//
|
||||||
|
// Token und URL anpassen!
|
||||||
|
|
||||||
|
const { rules, triggers, actions } = require('openhab');
|
||||||
|
|
||||||
|
const BRIDGE_URL = 'http://192.168.178.60:8090/api/v1/dialog';
|
||||||
|
const BRIDGE_TOKEN = 'REPLACE_WITH_BRIDGE_TOKEN';
|
||||||
|
|
||||||
|
rules.JSRule({
|
||||||
|
name: 'AI Bridge - Dialog senden',
|
||||||
|
description: 'Sendet Text aus AI_Bridge_DialogCommand an die Bridge und startet den Claude-Flow',
|
||||||
|
tags: ['AI', 'MCP'],
|
||||||
|
triggers: [triggers.ItemCommandTrigger('AI_Bridge_DialogCommand')],
|
||||||
|
execute: (event) => {
|
||||||
|
const text = String(event.receivedCommand || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
actions.HTTP.sendHttpPostRequest(
|
||||||
|
BRIDGE_URL,
|
||||||
|
'application/json',
|
||||||
|
JSON.stringify({
|
||||||
|
text: text,
|
||||||
|
title: 'openHAB Dialog',
|
||||||
|
notify_text: 'KI_DIALOG'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
'Authorization': 'Bearer ' + BRIDGE_TOKEN,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
8000
|
||||||
|
);
|
||||||
|
console.info('AI Bridge dialog sent: ' + text.substring(0, 80));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AI Bridge dialog failed: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Broker-UID an deine Installation anpassen:
|
||||||
|
// Dein Broker: mqtt:broker:72a3b87ef9
|
||||||
|
Thing mqtt:topic:ai_bridge "AI Bridge" (mqtt:broker:72a3b87ef9) {
|
||||||
|
Channels:
|
||||||
|
Type string : availability "Availability" [
|
||||||
|
stateTopic="openhab/ai_bridge/availability"
|
||||||
|
]
|
||||||
|
Type string : status "Status" [
|
||||||
|
stateTopic="openhab/ai_bridge/status"
|
||||||
|
]
|
||||||
|
Type string : last_response "Last response" [
|
||||||
|
stateTopic="openhab/ai_bridge/last_response"
|
||||||
|
]
|
||||||
|
Type string : active_session "Active session" [
|
||||||
|
stateTopic="openhab/ai_bridge/active_session"
|
||||||
|
]
|
||||||
|
Type string : notify "Notify" [
|
||||||
|
stateTopic="openhab/ai_bridge/notify"
|
||||||
|
]
|
||||||
|
Type string : dialog_input "Dialog input" [
|
||||||
|
stateTopic="openhab/ai_bridge/dialog/input"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.116.1
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
|
paho-mqtt==2.1.0
|
||||||
|
edge-tts==7.2.7
|
||||||
Reference in new issue
Block a user