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