Files
homelab-configs/src/web_server.py
T

281 lines
8.4 KiB
Python
Raw Normal View History

"""
FastAPI web server voor de Home Assistant Voice Control webinterface.
Biedt endpoints voor:
- Audio upload → transcriptie → HA actie
- Tekstcommando's
- Entity state opvragen
- Statische bestanden (frontend)
Start met:
uvicorn src.web_server:app --host 127.0.0.1 --port 8765
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
import pydantic
from src.ha_client import HAClient
from src import whisper_client
from src.dashboard_api import router as dashboard_router
import config
# ── logging ─────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("web-server")
# ── app ─────────────────────────────────────────────────────────────────────
app = FastAPI(
title="HA Voice Control",
description="Webinterface voor spraakgestuurde Home Assistant bediening",
version="1.0.0",
)
# CORS — sta externe toegang toe (voor nginx proxy / aparte frontend hosting)
app.add_middleware(
CORSMiddleware,
allow_origins=config.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
ha = HAClient()
# ── pydantic modellen ───────────────────────────────────────────────────────
class CommandRequest(pydantic.BaseModel):
text: str
class LightControlRequest(pydantic.BaseModel):
entity_id: str
action: str = "toggle" # turn_on, turn_off, toggle
brightness: int | None = None
color_name: str | None = None
class ServiceRequest(pydantic.BaseModel):
domain: str
service: str
entity_id: str | None = None
# ── API routes ──────────────────────────────────────────────────────────────
@app.get("/api/health")
async def health():
"""Health check endpoint."""
return {"status": "ok", "ha_url": config.HA_URL}
@app.post("/api/transcribe")
async def transcribe_audio_endpoint(audio: UploadFile = File(...)):
"""Upload audio, transcribeer met Whisper en voer uit via Home Assistant.
Accepteert WAV audio via multipart/form-data.
Retourneert de transcriptie en het HA-resultaat.
"""
if not audio.content_type or "audio" not in audio.content_type:
# Sta ook application/octet-stream toe (sommige browsers sturen dit)
logger.info("Content-Type is %s, doorgaan...", audio.content_type)
try:
audio_bytes = await audio.read()
except Exception as e:
logger.exception("Fout bij lezen audio")
raise HTTPException(400, f"Kan audio niet lezen: {e}")
if len(audio_bytes) == 0:
raise HTTPException(400, "Geen audio ontvangen (lege upload)")
logger.info("Audio ontvangen: %d bytes", len(audio_bytes))
# Transcriptie
try:
text = await whisper_client.transcribe(audio_bytes)
except Exception as e:
logger.exception("Transcriptie mislukt")
return JSONResponse(
{"error": f"Transcriptie fout: {e}", "text": "", "ha_result": None},
status_code=500,
)
if not text.strip():
return {
"text": "",
"ha_result": None,
"message": "Geen spraak gedetecteerd",
}
# Stuur naar Home Assistant conversation agent
try:
ha_result = await ha.process_conversation(text)
except Exception as e:
logger.exception("HA conversation agent fout")
return {
"text": text,
"ha_result": None,
"error": f"Home Assistant fout: {e}",
}
return {
"text": text,
"ha_result": ha_result,
}
@app.post("/api/command")
async def text_command(req: CommandRequest):
"""Stuur een tekstcommando naar de Home Assistant conversation agent."""
if not req.text.strip():
raise HTTPException(400, "Leeg commando")
try:
result = await ha.process_conversation(req.text.strip())
except Exception as e:
logger.exception("HA command fout")
raise HTTPException(500, f"Home Assistant fout: {e}")
return {"text": req.text, "ha_result": result}
@app.get("/api/lights")
async def list_lights():
"""Retourneer alle lampen met hun huidige status."""
try:
lights = await ha.list_lights()
except Exception as e:
raise HTTPException(500, f"Home Assistant fout: {e}")
return [
{
"entity_id": l.get("entity_id"),
"state": l.get("state"),
"friendly_name": (l.get("attributes", {}) or {}).get("friendly_name", ""),
"brightness": (l.get("attributes", {}) or {}).get("brightness"),
"color": (l.get("attributes", {}) or {}).get("rgb_color"),
}
for l in lights
]
@app.post("/api/light/control")
async def control_light(req: LightControlRequest):
"""Bedien een specifieke lamp."""
try:
result = await ha.control_light(
req.entity_id,
req.action,
req.brightness,
req.color_name,
)
except Exception as e:
raise HTTPException(500, f"Fout bij bedienen lamp: {e}")
return {"success": True, "result": result}
@app.get("/api/entities")
async def list_entities(domain: str | None = None):
"""Retourneer entities, optioneel gefilterd op domein."""
try:
entities = await ha.list_all_entities(domain)
except Exception as e:
raise HTTPException(500, f"Home Assistant fout: {e}")
# Samenvatting teruggeven om response beheersbaar te houden
return [
{
"entity_id": e.get("entity_id"),
"state": e.get("state"),
"friendly_name": (e.get("attributes", {}) or {}).get("friendly_name", ""),
}
for e in entities
]
@app.get("/api/entity/{entity_id}")
async def get_entity(entity_id: str):
"""Haal de volledige state van één entity op."""
try:
state = await ha.get_entity_state(entity_id)
except Exception as e:
raise HTTPException(500, f"Home Assistant fout: {e}")
return state
@app.post("/api/service")
async def call_service(req: ServiceRequest):
"""Roep een willekeurige Home Assistant service aan."""
target = {"entity_id": req.entity_id} if req.entity_id else None
try:
result = await ha.call_service(req.domain, req.service, target=target)
except Exception as e:
raise HTTPException(500, f"Service fout: {e}")
return {"success": True, "result": result}
# ── Dashboard API ───────────────────────────────────────────────────────────
app.include_router(dashboard_router)
# ── statische bestanden (frontend) ──────────────────────────────────────────
@app.get("/")
async def index():
"""Serveer de hoofdpagina (voice control)."""
return FileResponse(config.STATIC_DIR / "index.html")
@app.get("/dashboard")
async def dashboard():
"""Serveer het Home Dashboard."""
return FileResponse(config.STATIC_DIR / "dashboard.html")
# Mount static dir voor CSS/JS/favicon
if config.STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(config.STATIC_DIR)), name="static")
# ── entry point ─────────────────────────────────────────────────────────────
def main():
import uvicorn
logger.info(
"Starten web server op %s:%d ...", config.WEB_HOST, config.WEB_PORT
)
uvicorn.run(
"src.web_server:app",
host=config.WEB_HOST,
port=config.WEB_PORT,
reload=False,
log_level="info",
)
if __name__ == "__main__":
main()