"""HTTP API. The browser talks only to this backend; the backend talks to the robot. No robot address, port or credential ever reaches the page. """ from __future__ import annotations import logging import platform import time from typing import Any, Dict from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse from ..config.settings import reload_settings from ..core.events import EventType from ..robot.manager import RobotManager from ..services.audio_library import get_audio_library from ..services.speech_service import SpeechService from .schemas import SimpleResponse, SpeakRequest, SpeakResponse logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["robot"]) STARTED_AT = time.time() def _manager(request: Request) -> RobotManager: return request.app.state.robot_manager def _speech(request: Request) -> SpeechService: return request.app.state.speech_service # --------------------------------------------------------------------------- # # health / config # --------------------------------------------------------------------------- # @router.get("/health") async def health(request: Request) -> Dict[str, Any]: """Liveness of the *web app* - always 200, even with the robot offline.""" return { "status": "ok", "uptimeSeconds": round(time.time() - STARTED_AT, 1), "python": platform.python_version(), "mode": request.app.state.settings.robot.mode, } @router.get("/config") async def get_config(request: Request) -> Dict[str, Any]: return request.app.state.settings.public_dict() @router.post("/config/reload") async def post_config_reload(request: Request) -> Dict[str, Any]: """Re-read .env and rebuild the robot adapter without restarting the server. This is what you call after pasting the robot's IP into .env. """ settings = reload_settings() request.app.state.settings = settings _speech(request).update_settings(settings) await _manager(request).rebuild(settings) request.app.state.event_bus.publish(EventType.CONFIG_UPDATED, settings.public_dict()) logger.info("configuration reloaded: mode=%s address=%s", settings.robot.mode, settings.robot.address) return {"success": True, "config": settings.public_dict()} # --------------------------------------------------------------------------- # # robot # --------------------------------------------------------------------------- # @router.get("/robot/status") async def robot_status(request: Request) -> Dict[str, Any]: status = _manager(request).status_dict() status["busy"] = _speech(request).is_busy status["activeRequestId"] = _speech(request).active_request_id return status @router.get("/robot/diagnostics") async def robot_diagnostics(request: Request) -> Dict[str, Any]: manager = _manager(request) detail = await manager.adapter.describe() return { "status": manager.status_dict(), "adapter": detail, "config": request.app.state.settings.public_dict(), } @router.post("/robot/speak", response_model=SpeakResponse) async def robot_speak(payload: SpeakRequest, request: Request) -> Dict[str, Any]: """Send text to the robot. Returns once the robot has *acknowledged* the utterance. Speaking progress and completion arrive over the WebSocket at /ws. """ return await _speech(request).speak(payload.text, payload.voice, payload.language) @router.post("/robot/stop", response_model=SimpleResponse) async def robot_stop(request: Request) -> Dict[str, Any]: return await _speech(request).stop() @router.post("/robot/reconnect") async def robot_reconnect(request: Request) -> Dict[str, Any]: manager = _manager(request) manager.request_reconnect() return {"success": True, "status": manager.state.value} # --------------------------------------------------------------------------- # # history # --------------------------------------------------------------------------- # @router.get("/speech/history") async def speech_history(request: Request) -> Dict[str, Any]: service = _speech(request) items = service.annotate_saved(service.history.list()) return {"items": items, "count": len(items)} @router.delete("/speech/history") async def speech_history_delete(request: Request) -> Dict[str, Any]: return _speech(request).clear_history() @router.post("/speech/history/clear") async def speech_history_clear(request: Request) -> Dict[str, Any]: """Same as DELETE - kept because it is easier to call from a plain form/curl.""" return _speech(request).clear_history() # --------------------------------------------------------------------------- # # saved audio # --------------------------------------------------------------------------- # @router.get("/audio") async def audio_list() -> Dict[str, Any]: """Every line that has been synthesised and saved as a .wav.""" library = get_audio_library() return {"items": library.list(), "stats": library.stats()} @router.get("/audio/{audio_id}/file") async def audio_file(audio_id: str): """The .wav itself - playable in the browser, or downloadable.""" entry = get_audio_library().get(audio_id) if entry is None: raise HTTPException(status_code=404, detail="No saved audio with that id.") path = get_audio_library().root / entry["file"] return FileResponse( str(path), media_type="audio/wav", filename=entry["file"], headers={"Cache-Control": "public, max-age=31536000"}, ) @router.delete("/audio/{audio_id}") async def audio_delete(audio_id: str) -> Dict[str, Any]: if not get_audio_library().delete(audio_id): raise HTTPException(status_code=404, detail="No saved audio with that id.") return {"success": True} @router.delete("/audio") async def audio_clear() -> Dict[str, Any]: """Delete every saved clip. They are re-created on demand.""" return {"success": True, "removed": get_audio_library().clear()}