159 lines
4.6 KiB
Python
159 lines
4.6 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.dashboard.routes._safe_io import (
|
|
safe_path_under, check_upload_size, atomic_write_bytes,
|
|
)
|
|
|
|
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:
|
|
from Project.Sanad.core.logger import get_logger
|
|
get_logger("replay_route").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():
|
|
from Project.Sanad.main import arm
|
|
arm.cancel()
|
|
return {"ok": True, "message": "Cancel signal sent."}
|
|
|
|
|
|
@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()
|