Sanad/dashboard/routes/replay.py

167 lines
5.0 KiB
Python

"""Replay management endpoints — JSONL files, teaching, test replay, speed control.
Mirrors the replay management features from AI_Photographer/Server/photo_server.py.
"""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from pydantic import BaseModel
from Project.Sanad.config import MOTIONS_DIR
from Project.Sanad.core.logger import get_logger
from Project.Sanad.dashboard.routes._safe_io import (
safe_path_under, check_upload_size, atomic_write_bytes,
)
log = get_logger("replay_route")
router = APIRouter()
# -- models --
class ReplayRequest(BaseModel):
name: str
speed: float = 1.0
class RenameRequest(BaseModel):
old_name: str
new_name: str
class TeachRequest(BaseModel):
name: str
duration_sec: float = 15.0
# -- motion file CRUD --
@router.get("/files")
async def list_motion_files():
from Project.Sanad.main import arm
return {"files": arm.list_motion_files()}
@router.get("/files/{filename}")
async def download_motion_file(filename: str):
path = safe_path_under(MOTIONS_DIR, filename)
if not path.exists():
raise HTTPException(404, "File not found.")
return FileResponse(path, filename=path.name, media_type="application/json")
@router.post("/files/upload")
async def upload_motion_file(file: UploadFile = File(...)):
if not file.filename or not file.filename.lower().endswith(".jsonl"):
raise HTTPException(400, "Only .jsonl files accepted.")
MOTIONS_DIR.mkdir(parents=True, exist_ok=True)
dest = safe_path_under(MOTIONS_DIR, file.filename)
content = await file.read()
check_upload_size(content)
atomic_write_bytes(dest, content)
return {"ok": True, "name": dest.name, "size_bytes": len(content)}
@router.post("/files/rename")
async def rename_motion_file(payload: RenameRequest):
old = safe_path_under(MOTIONS_DIR, payload.old_name)
new = safe_path_under(MOTIONS_DIR, payload.new_name)
if not old.exists():
raise HTTPException(404, f"File not found: {payload.old_name}")
if new.exists():
raise HTTPException(409, f"File already exists: {payload.new_name}")
old.rename(new)
return {"ok": True, "old_name": old.name, "new_name": new.name}
@router.delete("/files/{filename}")
async def delete_motion_file(filename: str):
path = safe_path_under(MOTIONS_DIR, filename)
if not path.exists():
raise HTTPException(404, "File not found.")
path.unlink()
return {"ok": True, "deleted": path.name}
# -- test replay --
_BG_TASKS: set[asyncio.Task] = set()
@router.post("/test")
async def test_replay(payload: ReplayRequest):
"""Test-play a motion file at the given speed."""
from Project.Sanad.main import arm
if arm.is_busy:
raise HTTPException(409, "Arm is busy.")
path = safe_path_under(MOTIONS_DIR, payload.name)
if not path.exists():
raise HTTPException(404, f"Motion file not found: {path.name}")
async def _run():
try:
await asyncio.to_thread(arm.replay_file, str(path), payload.speed)
except Exception:
log.exception("Test replay failed")
task = asyncio.create_task(_run())
_BG_TASKS.add(task)
task.add_done_callback(_BG_TASKS.discard)
return {"ok": True, "name": path.name, "speed": payload.speed}
@router.post("/cancel")
async def cancel_replay():
"""Stop the current replay — the smooth return-to-home runs as the
final phase of the replay itself.
Matches g1_replay_v4_stable.py's behaviour: the play loop breaks on
the cancel flag, then the same Run() function executes its
return-home ramp + DisableSDK. No separate scheduling needed.
"""
from Project.Sanad.main import arm
arm.cancel()
return {"ok": True, "message": "Cancelled — returning to home pose smoothly."}
@router.get("/status")
async def replay_status():
from Project.Sanad.main import arm, teacher
return {
"arm": arm.status(),
"teaching": teacher.status() if teacher else {},
}
# -- teaching mode --
@router.post("/teach/start")
async def start_teaching(payload: TeachRequest):
from Project.Sanad.main import teacher
if teacher is None:
raise HTTPException(503, "Teaching module not available.")
if teacher.is_recording:
raise HTTPException(409, "Teaching session already active.")
existing = MOTIONS_DIR / f"{payload.name}.jsonl"
if existing.exists():
raise HTTPException(409, f"Motion file already exists: {payload.name}.jsonl")
return teacher.start(payload.name, payload.duration_sec)
@router.post("/teach/stop")
async def stop_teaching():
from Project.Sanad.main import teacher
if teacher is None:
raise HTTPException(503, "Teaching module not available.")
return teacher.stop()
@router.get("/teach/status")
async def teaching_status():
from Project.Sanad.main import teacher
if teacher is None:
return {"recording": False, "phase": "idle"}
return teacher.status()