2026-09-03 00:10:18 +04:00

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