173 lines
5.9 KiB
Python
173 lines
5.9 KiB
Python
"""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()}
|