176 lines
5.2 KiB
Python
176 lines
5.2 KiB
Python
"""Application entry point.
|
|
|
|
python backend/main.py -> http://localhost:8000
|
|
|
|
Serves the dashboard, the JSON API and the WebSocket from one process, so there
|
|
is nothing to orchestrate on the demo machine.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
# Allow `python backend/main.py` as well as `python -m backend.main`.
|
|
if __package__ in (None, ""): # pragma: no cover
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from fastapi import FastAPI, Request # noqa: E402
|
|
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
|
from fastapi.responses import JSONResponse # noqa: E402
|
|
from fastapi.staticfiles import StaticFiles # noqa: E402
|
|
|
|
from backend.api.routes import router as api_router # noqa: E402
|
|
from backend.api.websocket import ws_router # noqa: E402
|
|
from backend.config.settings import PROJECT_ROOT, get_settings # noqa: E402
|
|
from backend.core.events import get_event_bus # noqa: E402
|
|
from backend.core.logging import configure_logging # noqa: E402
|
|
from backend.robot.base import ( # noqa: E402
|
|
RobotBusy,
|
|
RobotError,
|
|
RobotNotConfigured,
|
|
RobotTimeout,
|
|
RobotUnreachable,
|
|
SpeechFailed,
|
|
TransportNotAvailable,
|
|
)
|
|
from backend.robot.manager import RobotManager # noqa: E402
|
|
from backend.services.speech_service import SpeechService, ValidationError # noqa: E402
|
|
|
|
logger = logging.getLogger("agibot.app")
|
|
|
|
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
|
|
|
# HTTP status for each robot-layer failure. Anything unmapped becomes 500.
|
|
STATUS_FOR_ERROR = {
|
|
ValidationError: 400,
|
|
RobotBusy: 409,
|
|
TransportNotAvailable: 501,
|
|
SpeechFailed: 502,
|
|
RobotNotConfigured: 503,
|
|
RobotUnreachable: 503,
|
|
RobotTimeout: 504,
|
|
}
|
|
|
|
|
|
def _status_for(exc: RobotError) -> int:
|
|
for error_type, status in STATUS_FOR_ERROR.items():
|
|
if isinstance(exc, error_type):
|
|
return status
|
|
return 500
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
settings = app.state.settings
|
|
manager: RobotManager = app.state.robot_manager
|
|
|
|
banner(settings)
|
|
await manager.start()
|
|
try:
|
|
yield
|
|
finally:
|
|
await app.state.speech_service.shutdown()
|
|
await manager.stop()
|
|
logger.info("shutdown complete")
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
settings = get_settings()
|
|
configure_logging(settings.server.log_level)
|
|
|
|
app = FastAPI(
|
|
title="AGIBOT A3 Voice Control",
|
|
description="Local dashboard for sending speech to an AGIBOT A3 humanoid.",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
docs_url="/api/docs",
|
|
openapi_url="/api/openapi.json",
|
|
)
|
|
|
|
bus = get_event_bus()
|
|
manager = RobotManager(settings, bus)
|
|
app.state.settings = settings
|
|
app.state.event_bus = bus
|
|
app.state.robot_manager = manager
|
|
app.state.speech_service = SpeechService(settings, manager, bus)
|
|
|
|
if settings.server.cors_origins:
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.server.cors_origins,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
@app.exception_handler(RobotError)
|
|
async def robot_error_handler(request: Request, exc: RobotError) -> JSONResponse:
|
|
status = _status_for(exc)
|
|
if status >= 500:
|
|
logger.warning("%s -> %s (%s)", request.url.path, exc.code, exc)
|
|
return JSONResponse(
|
|
status_code=status,
|
|
content={"success": False, "error": exc.user_message, "errorCode": exc.code},
|
|
)
|
|
|
|
app.include_router(api_router)
|
|
app.include_router(ws_router)
|
|
|
|
if FRONTEND_DIR.is_dir():
|
|
# Mounted last so /api/* and /ws win.
|
|
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
|
|
else: # pragma: no cover
|
|
logger.error("frontend directory missing: %s", FRONTEND_DIR)
|
|
|
|
return app
|
|
|
|
|
|
def banner(settings) -> None:
|
|
mode = settings.robot.mode.upper()
|
|
target = "local simulation" if settings.is_mock else "{0} via {1}".format(
|
|
settings.robot.address, settings.a3.transport
|
|
)
|
|
url = "http://{0}:{1}".format(
|
|
"localhost" if settings.server.host in ("0.0.0.0", "127.0.0.1") else settings.server.host,
|
|
settings.server.port,
|
|
)
|
|
lines = [
|
|
"",
|
|
" AGIBOT A3 - Voice Control",
|
|
" " + "-" * 46,
|
|
" Mode : {0}".format(mode),
|
|
" Robot : {0}".format(target),
|
|
" Dashboard : {0}".format(url),
|
|
" Config : {0}".format(settings.env_file),
|
|
]
|
|
for issue in settings.issues:
|
|
lines.append(" {0:<10}: [{1}] {2}".format("Config", issue.level.upper(), issue.message))
|
|
if settings.is_mock:
|
|
lines.append(" Note : running against the MOCK robot - no hardware needed.")
|
|
lines.append("")
|
|
print("\n".join(lines))
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
def main() -> None:
|
|
import uvicorn
|
|
|
|
settings = get_settings()
|
|
uvicorn.run(
|
|
"backend.main:app",
|
|
host=settings.server.host,
|
|
port=settings.server.port,
|
|
reload=os.environ.get("DEV_RELOAD", "").lower() in ("1", "true", "yes"),
|
|
log_level=settings.server.log_level,
|
|
access_log=False,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|