Files
gps-denied-onboard/src/gps_denied/api/routers/flights.py
T

194 lines
6.2 KiB
Python

"""REST API Endpoints for Flight Management."""
from __future__ import annotations
import asyncio
import json
from typing import Annotated
from fastapi import APIRouter, File, Form, HTTPException, Path, UploadFile
from sse_starlette.sse import EventSourceResponse
from gps_denied.api.deps import ProcessorDep, SessionDep
from gps_denied.schemas.flight import (
BatchMetadata,
BatchResponse,
BatchUpdateResponse,
DeleteResponse,
FlightCreateRequest,
FlightDetailResponse,
FlightResponse,
FlightStatusResponse,
ObjectGPSResponse,
ObjectToGPSRequest,
UpdateResponse,
UserFixRequest,
UserFixResponse,
Waypoint,
)
router = APIRouter(prefix="/flights", tags=["flights"])
@router.post("", response_model=FlightResponse, status_code=201)
async def create_flight(
req: FlightCreateRequest,
processor: ProcessorDep,
session: SessionDep,
) -> FlightResponse:
"""Create a new flight and trigger prefetching."""
res = await processor.create_flight(req)
await session.commit()
return res
@router.get("/{flight_id}", response_model=FlightDetailResponse)
async def get_flight(
flight_id: Annotated[str, Path(..., title="The ID of the flight")],
processor: ProcessorDep,
) -> FlightDetailResponse:
"""Get complete flight information."""
res = await processor.get_flight(flight_id)
if not res:
raise HTTPException(status_code=404, detail="Flight not found")
return res
@router.delete("/{flight_id}", response_model=DeleteResponse)
async def delete_flight(
flight_id: Annotated[str, Path(...)],
processor: ProcessorDep,
session: SessionDep,
) -> DeleteResponse:
"""Delete a flight and all associated data."""
res = await processor.delete_flight(flight_id)
if not res.deleted:
raise HTTPException(status_code=404, detail="Flight not found")
await session.commit()
return res
@router.put("/{flight_id}/waypoints/{waypoint_id}", response_model=UpdateResponse)
async def update_waypoint(
flight_id: Annotated[str, Path(...)],
waypoint_id: Annotated[str, Path(...)],
waypoint: Waypoint,
processor: ProcessorDep,
session: SessionDep,
) -> UpdateResponse:
"""Update a specific waypoint."""
res = await processor.update_waypoint(flight_id, waypoint_id, waypoint)
if not res.updated:
raise HTTPException(status_code=404, detail="Waypoint or Flight not found")
await session.commit()
return res
@router.put("/{flight_id}/waypoints/batch", response_model=BatchUpdateResponse)
async def batch_update_waypoints(
flight_id: Annotated[str, Path(...)],
waypoints: list[Waypoint],
processor: ProcessorDep,
session: SessionDep,
) -> BatchUpdateResponse:
"""Batch update multiple waypoints."""
res = await processor.batch_update_waypoints(flight_id, waypoints)
await session.commit()
return res
@router.post("/{flight_id}/images/batch", response_model=BatchResponse, status_code=202)
async def upload_image_batch(
flight_id: Annotated[str, Path(...)],
metadata: Annotated[str, Form(...)],
images: list[UploadFile] = File(...),
processor: ProcessorDep = None, # type: ignore
session: SessionDep = None, # type: ignore
) -> BatchResponse:
"""Upload a batch of UAV images."""
try:
meta_dict = json.loads(metadata)
meta_obj = BatchMetadata(**meta_dict)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid metadata JSON: {e}")
f_info = await processor.get_flight(flight_id)
if not f_info:
raise HTTPException(status_code=404, detail="Flight not found")
# P1#6: Batch size validation (allow 1-50 for dev, spec says 10-50)
if len(images) < 1 or len(images) > 50:
raise HTTPException(
status_code=422,
detail=f"Batch must contain 1-50 images, got {len(images)}",
)
res = await processor.queue_images(flight_id, meta_obj, len(images))
# P1#5: Spawn background task to process each frame
import cv2
import numpy as np
async def _process_batch():
for idx, upload in enumerate(images):
raw = await upload.read()
arr = np.frombuffer(raw, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is not None:
frame_id = meta_obj.start_frame_id + idx
await processor.process_frame(flight_id, frame_id, img)
asyncio.create_task(_process_batch())
await session.commit()
return res
@router.post("/{flight_id}/user-fix", response_model=UserFixResponse)
async def submit_user_fix(
flight_id: Annotated[str, Path(...)],
fix_data: UserFixRequest,
processor: ProcessorDep,
session: SessionDep,
) -> UserFixResponse:
"""Submit a verified GPS anchor to unblock processing."""
res = await processor.handle_user_fix(flight_id, fix_data)
await session.commit()
return res
@router.post("/{flight_id}/frames/{frame_id}/object-to-gps", response_model=ObjectGPSResponse)
async def convert_object_to_gps(
flight_id: Annotated[str, Path(...)],
frame_id: Annotated[int, Path(...)],
req: ObjectToGPSRequest,
processor: ProcessorDep,
) -> ObjectGPSResponse:
"""Convert a pixel coordinate to GPS coordinate for an object."""
return await processor.convert_object_to_gps(flight_id, frame_id, (req.pixel_x, req.pixel_y))
@router.get("/{flight_id}/status", response_model=FlightStatusResponse)
async def get_flight_status(
flight_id: Annotated[str, Path(...)],
processor: ProcessorDep,
) -> FlightStatusResponse:
"""Get processing status of a flight."""
res = await processor.get_flight_status(flight_id)
if not res:
raise HTTPException(status_code=404, detail="Flight not found")
return res
@router.get("/{flight_id}/stream")
async def create_sse_stream(
flight_id: Annotated[str, Path(...)],
processor: ProcessorDep,
) -> EventSourceResponse:
"""SSE endpoint for real-time processing events."""
f_info = await processor.get_flight(flight_id)
if not f_info:
raise HTTPException(status_code=404, detail="Flight not found")
return EventSourceResponse(processor.stream_events(flight_id, client_id="default"))