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