import logging from datetime import datetime from typing import List, Optional, Tuple, Dict, Any from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form, Request from pydantic import BaseModel from sse_starlette.sse import EventSourceResponse # Import core data models from f02_1_flight_lifecycle_manager import ( FlightLifecycleManager, GPSPoint, CameraParameters, Waypoint, UserFixRequest, FlightState ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/flights", tags=["Flight Management"]) # --- Dependency Injection --- def get_lifecycle_manager() -> FlightLifecycleManager: """ Dependency placeholder for the Flight Lifecycle Manager. This will be overridden in main.py during app startup. """ raise NotImplementedError("FlightLifecycleManager dependency not overridden.") def get_flight_database(): """Dependency for direct DB access if bypassed by manager for simple CRUD.""" raise NotImplementedError("FlightDatabase dependency not overridden.") # --- API Data Models --- class Polygon(BaseModel): north_west: GPSPoint south_east: GPSPoint class Geofences(BaseModel): polygons: List[Polygon] = [] class FlightCreateRequest(BaseModel): name: str description: str = "" start_gps: GPSPoint rough_waypoints: List[GPSPoint] = [] geofences: Geofences = Geofences() camera_params: CameraParameters altitude: float class FlightResponse(BaseModel): flight_id: str status: str message: Optional[str] = None created_at: datetime class FlightDetailResponse(BaseModel): flight_id: str name: str description: str start_gps: GPSPoint waypoints: List[Waypoint] camera_params: CameraParameters altitude: float status: str frames_processed: int frames_total: int created_at: datetime updated_at: datetime class DeleteResponse(BaseModel): deleted: bool flight_id: str class UpdateResponse(BaseModel): updated: bool waypoint_id: str class BatchUpdateResponse(BaseModel): success: bool updated_count: int failed_ids: List[str] class BatchResponse(BaseModel): accepted: bool sequences: List[int] = [] next_expected: int = 0 message: Optional[str] = None class UserFixResponse(BaseModel): accepted: bool processing_resumed: bool message: Optional[str] = None class ObjectToGPSRequest(BaseModel): pixel_x: float pixel_y: float class ObjectGPSResponse(BaseModel): gps: GPSPoint accuracy_meters: float frame_id: int pixel: Tuple[float, float] class FlightStatusResponse(BaseModel): status: str frames_processed: int frames_total: int has_active_engine: bool class ResultResponse(BaseModel): image_id: str sequence_number: int estimated_gps: GPSPoint confidence: float source: str class CandidateTile(BaseModel): tile_id: str image_url: str center_gps: GPSPoint class FrameContextResponse(BaseModel): frame_id: int uav_image_url: str satellite_candidates: List[CandidateTile] # --- Internal Validation & Builder Methods (Feature 01.01) --- def _validate_gps_coordinates(lat: float, lon: float) -> bool: """Validate GPS coordinate ranges.""" return -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0 def _validate_camera_params(params: CameraParameters) -> bool: """Validate camera parameter values.""" if params.focal_length_mm <= 0 or params.sensor_width_mm <= 0: return False if "width" not in params.resolution or "height" not in params.resolution: return False return True def _validate_geofences(geofences: Geofences) -> bool: """Validate geofence polygon data.""" for poly in geofences.polygons: if not _validate_gps_coordinates(poly.north_west.lat, poly.north_west.lon): return False if not _validate_gps_coordinates(poly.south_east.lat, poly.south_east.lon): return False return True def _build_flight_response(flight_id: str, status: str, message: str) -> FlightResponse: """Build response from F02 result.""" return FlightResponse(flight_id=flight_id, status=status, message=message, created_at=datetime.utcnow()) def _build_status_response(state: FlightState) -> FlightStatusResponse: """Build status response.""" return FlightStatusResponse( status=state.state, frames_processed=state.processed_images, frames_total=state.total_images, has_active_engine=state.has_active_engine ) # --- Internal Validation & Builder Methods (Feature 01.02) --- def _validate_batch_size(images: List[UploadFile]) -> bool: """Validate batch contains 10-50 images.""" return 10 <= len(images) <= 50 def _validate_sequence_numbers(start_seq: int, end_seq: int, count: int) -> bool: """Validate start/end sequence are valid.""" if start_seq > end_seq: return False if (end_seq - start_seq + 1) != count: return False return True def _validate_image_format(content_type: str, filename: str) -> bool: """Validate image file is valid JPEG/PNG.""" if content_type not in ["image/jpeg", "image/png"]: return False if not any(filename.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png"]): return False return True def _build_batch_response(accepted: bool, start_seq: int, end_seq: int, message: str) -> BatchResponse: """Build response with accepted sequences.""" sequences = list(range(start_seq, end_seq + 1)) if accepted else [] next_expected = end_seq + 1 if accepted else start_seq return BatchResponse(accepted=accepted, sequences=sequences, next_expected=next_expected, message=message) # --- Endpoints --- @router.get("", response_model=List[FlightResponse]) async def list_flights( status: Optional[str] = None, limit: int = 10, db: Any = Depends(get_flight_database) ): """Retrieves a list of all flights matching the optional status filter.""" if not db: raise HTTPException(status_code=500, detail="Database dependency missing.") filters = {"state": status} if status else None flights = db.query_flights(filters=filters, limit=limit) return [ FlightResponse( flight_id=f.flight_id, status=f.state, message="Retrieved successfully.", created_at=f.created_at ) for f in flights ] @router.post("", response_model=FlightResponse, status_code=201) async def create_flight( request: FlightCreateRequest, manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """Creates a new flight, initializes its origin, and triggers pre-flight satellite tile prefetching.""" if not _validate_gps_coordinates(request.start_gps.lat, request.start_gps.lon): raise HTTPException(status_code=400, detail="Invalid GPS coordinates.") if not _validate_camera_params(request.camera_params): raise HTTPException(status_code=400, detail="Invalid camera parameters.") if not _validate_geofences(request.geofences): raise HTTPException(status_code=400, detail="Invalid geofence coordinates.") try: flight_data = { "flight_name": request.name, "start_gps": request.start_gps.model_dump(), "altitude_m": request.altitude, "camera_params": request.camera_params.model_dump(), "state": "prefetching" } flight_id = manager.create_flight(flight_data) return _build_flight_response(flight_id, "prefetching", "Flight created. Satellite prefetching initiated asynchronously.") except Exception as e: logger.error(f"Flight creation failed: {e}") raise HTTPException(status_code=500, detail="Internal server error during flight creation.") @router.get("/{flight_id}", response_model=FlightDetailResponse) async def get_flight( flight_id: str, manager: FlightLifecycleManager = Depends(get_lifecycle_manager), db: Any = Depends(get_flight_database) ): """Retrieves complete flight details including its waypoints and processing state.""" flight = manager.get_flight(flight_id) if not flight: raise HTTPException(status_code=404, detail="Flight not found.") state = manager.get_flight_state(flight_id) waypoints = db.get_waypoints(flight_id) if db else [] return FlightDetailResponse( flight_id=flight.flight_id, name=flight.flight_name, description="", # Simplified for payload start_gps=flight.start_gps, waypoints=waypoints, camera_params=flight.camera_params, altitude=flight.altitude_m, status=state.state if state else flight.state, frames_processed=state.processed_images if state else 0, frames_total=state.total_images if state else 0, created_at=flight.created_at, updated_at=flight.updated_at ) @router.delete("/{flight_id}", response_model=DeleteResponse) async def delete_flight( flight_id: str, manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """Stops processing, purges cached tiles, and deletes the flight trajectory from the database.""" if manager.delete_flight(flight_id): return DeleteResponse(deleted=True, flight_id=flight_id) raise HTTPException(status_code=404, detail="Flight not found or could not be deleted.") @router.put("/{flight_id}/waypoints/batch", response_model=BatchUpdateResponse) async def batch_update_waypoints( flight_id: str, waypoints: List[Waypoint], db: Any = Depends(get_flight_database) ): """Asynchronously batch-updates trajectory waypoints after factor graph convergence.""" if not db: raise HTTPException(status_code=500, detail="Database dependency missing.") result = db.batch_update_waypoints(flight_id, waypoints) return BatchUpdateResponse( success=len(result.failed_ids) == 0, updated_count=result.updated_count, failed_ids=result.failed_ids ) @router.put("/{flight_id}/waypoints/{waypoint_id}", response_model=UpdateResponse) async def update_waypoint( flight_id: str, waypoint_id: str, waypoint: Waypoint, db: Any = Depends(get_flight_database) ): """Updates a single waypoint (e.g., manual refinement).""" if db and db.update_waypoint(flight_id, waypoint_id, waypoint): return UpdateResponse(updated=True, waypoint_id=waypoint_id) raise HTTPException(status_code=404, detail="Waypoint or Flight not found.") @router.post("/{flight_id}/images/batch", response_model=BatchResponse, status_code=202) async def upload_image_batch( flight_id: str, start_sequence: int = Form(...), end_sequence: int = Form(...), batch_number: int = Form(...), images: List[UploadFile] = File(...), manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """Ingests a sequential batch of UAV images and pushes them onto the Flight Processing Engine queue.""" if not _validate_batch_size(images): raise HTTPException(status_code=400, detail="Batch size must be between 10 and 50 images.") if not _validate_sequence_numbers(start_sequence, end_sequence, len(images)): raise HTTPException(status_code=400, detail="Invalid sequence numbers or gap detected.") for img in images: if not _validate_image_format(img.content_type, img.filename): raise HTTPException(status_code=400, detail=f"Invalid image format for {img.filename}. Must be JPEG or PNG.") from f05_image_input_pipeline import ImageBatch # Load byte data securely image_bytes = [await img.read() for img in images] filenames = [img.filename for img in images] total_size = sum(len(b) for b in image_bytes) if total_size > 500 * 1024 * 1024: # 500MB batch limit raise HTTPException(status_code=413, detail="Batch size exceeds 500MB limit.") batch = ImageBatch( images=image_bytes, filenames=filenames, start_sequence=start_sequence, end_sequence=end_sequence, batch_number=batch_number ) if manager.queue_images(flight_id, batch): return _build_batch_response(True, start_sequence, end_sequence, "Batch queued for processing.") raise HTTPException(status_code=400, detail="Batch validation failed.") @router.post("/{flight_id}/user-fix", response_model=UserFixResponse) async def submit_user_fix( flight_id: str, fix_data: UserFixRequest, manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """Provides a manual hard geodetic anchor when autonomous recovery fails (AC-6).""" result = manager.handle_user_fix(flight_id, fix_data) if result.get("status") == "success": return UserFixResponse(accepted=True, processing_resumed=True, message=result.get("message")) error_msg = result.get("message", "Fix rejected.") if "not in blocked state" in error_msg.lower(): raise HTTPException(status_code=409, detail=error_msg) if "not found" in error_msg.lower(): raise HTTPException(status_code=404, detail=error_msg) raise HTTPException(status_code=400, detail=error_msg) @router.get("/{flight_id}/status", response_model=FlightStatusResponse) async def get_flight_status( flight_id: str, manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """Retrieves the real-time processing and pipeline state of the flight.""" state = manager.get_flight_state(flight_id) if not state: raise HTTPException(status_code=404, detail="Flight not found.") return _build_status_response(state) @router.get("/{flight_id}/results", response_model=List[ResultResponse]) async def get_flight_results( flight_id: str, manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """Retrieves computed flight results.""" results = manager.get_flight_results(flight_id) if results is None: raise HTTPException(status_code=404, detail="Flight not found.") return results @router.get("/{flight_id}/stream") async def create_sse_stream( flight_id: str, request: Request, manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """Opens a Server-Sent Events (SSE) stream for sub-millisecond, low-latency trajectory updates.""" if not manager.get_flight(flight_id): raise HTTPException(status_code=404, detail="Flight not found.") stream_generator = manager.create_client_stream(flight_id, client_id=request.client.host) if not stream_generator: raise HTTPException(status_code=500, detail="Failed to initialize telemetry stream.") return EventSourceResponse(stream_generator) @router.post("/{flight_id}/frames/{frame_id}/object-to-gps", response_model=ObjectGPSResponse) async def convert_object_to_gps( flight_id: str, frame_id: int, request: ObjectToGPSRequest, manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """ Calculates the absolute GPS coordinate of an object selected by a user pixel click. Utilizes Ray-Cloud intersection for high precision (AC-2/AC-10). """ if request.pixel_x < 0 or request.pixel_y < 0: raise HTTPException(status_code=400, detail="Invalid pixel coordinates: must be non-negative.") try: gps_point = manager.convert_object_to_gps(flight_id, frame_id, (request.pixel_x, request.pixel_y)) if not gps_point: raise HTTPException(status_code=409, detail="Frame not yet processed or pose unavailable.") return ObjectGPSResponse( gps=gps_point, accuracy_meters=5.0, frame_id=frame_id, pixel=(request.pixel_x, request.pixel_y) ) except ValueError as ve: raise HTTPException(status_code=400, detail=str(ve)) except Exception: raise HTTPException(status_code=404, detail="Flight or frame not found.") @router.get("/{flight_id}/frames/{frame_id}/context", response_model=FrameContextResponse) async def get_frame_context( flight_id: str, frame_id: int, manager: FlightLifecycleManager = Depends(get_lifecycle_manager) ): """ Retrieves the UAV image and top candidate satellite tiles to assist the user in providing a manual GPS fix when the system is blocked. """ context = manager.get_frame_context(flight_id, frame_id) if not context: raise HTTPException(status_code=404, detail="Context not found for this flight or frame.") return FrameContextResponse(**context)