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