from __future__ import annotations import os import time from pathlib import Path from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel import bag_metadata import config router = APIRouter(tags=["recording"]) # Directory browsing/recording is restricted to the user's home tree — the # browser has no server-filesystem access, so /api/fs/browse is a small # custom endpoint standing in for a native folder picker. BROWSE_ROOT = Path.home() def _safe_path(raw: str) -> Path: p = (BROWSE_ROOT / raw).resolve() if not os.path.isabs(raw) else Path(raw).resolve() try: p.relative_to(BROWSE_ROOT.resolve()) except ValueError: raise HTTPException(400, f"path must be under {BROWSE_ROOT}") return p @router.get("/fs/browse") async def browse(path: str = str(BROWSE_ROOT)): p = _safe_path(path) if not p.exists() or not p.is_dir(): raise HTTPException(404, "not a directory") entries = [] for child in sorted(p.iterdir()): if child.is_dir(): entries.append({"name": child.name, "path": str(child)}) return {"path": str(p), "parent": str(p.parent) if p != BROWSE_ROOT.resolve() else None, "dirs": entries} @router.get("/bags") async def list_bags(save_dir: str = config.DEFAULT_SAVE_DIR, limit: int = 10): d = Path(save_dir) if not d.exists(): return {"bags": []} bags = [] children = sorted(d.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True) for child in children: if not child.is_dir(): continue if len(bags) >= limit: break meta = bag_metadata.read_bag_metadata(child) bags.append({ "name": child.name, "path": str(child), "mtime": child.stat().st_mtime, "duration_s": meta["duration_s"] if meta else None, "message_count": meta["message_count"] if meta else None, "topics": meta["topics"] if meta else [], "size_bytes": meta["size_bytes"] if meta else bag_metadata.dir_size_bytes(child), "complete": meta is not None, # False = still recording, or was killed before flush }) return {"bags": bags} @router.get("/fs/disk-usage") async def disk_usage(path: str = config.DEFAULT_SAVE_DIR): return {"free_gb": round(config.disk_free_gb(path), 2)} class RecordingStartIn(BaseModel): filename: str save_dir: str = config.DEFAULT_SAVE_DIR @router.post("/recording/start") async def recording_start(body: RecordingStartIn, request: Request): session = request.app.state.session pm = request.app.state.pm if not pm.is_running("lidar") or not pm.is_running("camera"): raise HTTPException(409, "start lidar+camera before recording") if session.recording: raise HTTPException(409, "already recording") if not body.filename.strip(): raise HTTPException(400, "filename required") save_dir = body.save_dir.strip() or config.DEFAULT_SAVE_DIR os.makedirs(save_dir, exist_ok=True) bag_path = os.path.join(save_dir, body.filename.strip()) topics = config.base_record_topics(session.camera_count) # FAST-LIO tracks the scan (LiDAR+IMU-only mapping) for exactly the # recording's lifetime — started alongside the bag, stopped alongside it. # It only needs /livox/lidar + /livox/imu, both already live since # lidar+camera are required (checked above) before recording can start. request.app.state.ros.pointcloud.reset() # fresh map for this recording, not last one's leftovers pm.start("fastlio", config.FASTLIO_LAUNCH_CMD, log_path=config.FASTLIO_LOG_PATH, env=config.fastlio_env()) cmd = f"{config.ROSBAG_SOURCE} && stdbuf -oL -eL ros2 bag record -o {bag_path} {topics}" pm.start("recording", cmd, log_path=config.RECORDING_LOG_PATH) session.recording = True session.recording_path = bag_path session.recording_started_at = time.monotonic() return {"ok": True, "path": bag_path} @router.post("/recording/stop") async def recording_stop(request: Request): session = request.app.state.session pm = request.app.state.pm pm.stop("recording") pm.stop("fastlio") session.recording = False session.recording_path = None session.recording_started_at = None return {"ok": True}