""" 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