281 lines
8.4 KiB
Python
281 lines
8.4 KiB
Python
|
|
"""
|
||
|
|
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()
|