Files
usda-vision/usda_vision_system/api/server.py

440 lines
18 KiB
Python

"""
FastAPI Server for the USDA Vision Camera System.
This module provides REST API endpoints and WebSocket support for dashboard integration.
"""
import asyncio
import logging
import json
from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta
import threading
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import uvicorn
from ..core.config import Config
from ..core.state_manager import StateManager
from ..core.events import EventSystem, EventType, Event
from ..storage.manager import StorageManager
from .models import *
class WebSocketManager:
"""Manages WebSocket connections for real-time updates"""
def __init__(self):
self.active_connections: List[WebSocket] = []
self.logger = logging.getLogger(f"{__name__}.WebSocketManager")
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
self.logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
self.logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
async def send_personal_message(self, message: dict, websocket: WebSocket):
try:
await websocket.send_text(json.dumps(message))
except Exception as e:
self.logger.error(f"Error sending personal message: {e}")
async def broadcast(self, message: dict):
if not self.active_connections:
return
disconnected = []
for connection in self.active_connections:
try:
await connection.send_text(json.dumps(message))
except Exception as e:
self.logger.error(f"Error broadcasting to connection: {e}")
disconnected.append(connection)
# Remove disconnected connections
for connection in disconnected:
self.disconnect(connection)
class APIServer:
"""FastAPI server for the USDA Vision Camera System"""
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem,
camera_manager, mqtt_client, storage_manager: StorageManager):
self.config = config
self.state_manager = state_manager
self.event_system = event_system
self.camera_manager = camera_manager
self.mqtt_client = mqtt_client
self.storage_manager = storage_manager
self.logger = logging.getLogger(__name__)
# FastAPI app
self.app = FastAPI(
title="USDA Vision Camera System API",
description="API for monitoring and controlling the USDA vision camera system",
version="1.0.0"
)
# WebSocket manager
self.websocket_manager = WebSocketManager()
# Server state
self.server_start_time = datetime.now()
self.running = False
self._server_thread: Optional[threading.Thread] = None
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
# Setup CORS
self.app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Setup routes
self._setup_routes()
# Subscribe to events for WebSocket broadcasting
self._setup_event_subscriptions()
def _setup_routes(self):
"""Setup API routes"""
@self.app.get("/", response_model=SuccessResponse)
async def root():
return SuccessResponse(message="USDA Vision Camera System API")
@self.app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
@self.app.get("/system/status", response_model=SystemStatusResponse)
async def get_system_status():
"""Get overall system status"""
try:
summary = self.state_manager.get_system_summary()
uptime = (datetime.now() - self.server_start_time).total_seconds()
return SystemStatusResponse(
system_started=summary["system_started"],
mqtt_connected=summary["mqtt_connected"],
last_mqtt_message=summary["last_mqtt_message"],
machines=summary["machines"],
cameras=summary["cameras"],
active_recordings=summary["active_recordings"],
total_recordings=summary["total_recordings"],
uptime_seconds=uptime
)
except Exception as e:
self.logger.error(f"Error getting system status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/machines", response_model=Dict[str, MachineStatusResponse])
async def get_machines():
"""Get all machine statuses"""
try:
machines = self.state_manager.get_all_machines()
return {
name: MachineStatusResponse(
name=machine.name,
state=machine.state.value,
last_updated=machine.last_updated.isoformat(),
last_message=machine.last_message,
mqtt_topic=machine.mqtt_topic
)
for name, machine in machines.items()
}
except Exception as e:
self.logger.error(f"Error getting machines: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras", response_model=Dict[str, CameraStatusResponse])
async def get_cameras():
"""Get all camera statuses"""
try:
cameras = self.state_manager.get_all_cameras()
return {
name: CameraStatusResponse(
name=camera.name,
status=camera.status.value,
is_recording=camera.is_recording,
last_checked=camera.last_checked.isoformat(),
last_error=camera.last_error,
device_info=camera.device_info,
current_recording_file=camera.current_recording_file,
recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None
)
for name, camera in cameras.items()
}
except Exception as e:
self.logger.error(f"Error getting cameras: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse)
async def get_camera_status(camera_name: str):
"""Get specific camera status"""
try:
camera = self.state_manager.get_camera_status(camera_name)
if not camera:
raise HTTPException(status_code=404, detail=f"Camera not found: {camera_name}")
return CameraStatusResponse(
name=camera.name,
status=camera.status.value,
is_recording=camera.is_recording,
last_checked=camera.last_checked.isoformat(),
last_error=camera.last_error,
device_info=camera.device_info,
current_recording_file=camera.current_recording_file,
recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None
)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error getting camera status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse)
async def start_recording(camera_name: str, request: StartRecordingRequest):
"""Manually start recording for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_start_recording(camera_name, request.filename)
if success:
return StartRecordingResponse(
success=True,
message=f"Recording started for {camera_name}",
filename=request.filename
)
else:
return StartRecordingResponse(
success=False,
message=f"Failed to start recording for {camera_name}"
)
except Exception as e:
self.logger.error(f"Error starting recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse)
async def stop_recording(camera_name: str):
"""Manually stop recording for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_stop_recording(camera_name)
if success:
return StopRecordingResponse(
success=True,
message=f"Recording stopped for {camera_name}"
)
else:
return StopRecordingResponse(
success=False,
message=f"Failed to stop recording for {camera_name}"
)
except Exception as e:
self.logger.error(f"Error stopping recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/recordings", response_model=Dict[str, RecordingInfoResponse])
async def get_recordings():
"""Get all recording sessions"""
try:
recordings = self.state_manager.get_all_recordings()
return {
rid: RecordingInfoResponse(
camera_name=recording.camera_name,
filename=recording.filename,
start_time=recording.start_time.isoformat(),
state=recording.state.value,
end_time=recording.end_time.isoformat() if recording.end_time else None,
file_size_bytes=recording.file_size_bytes,
frame_count=recording.frame_count,
duration_seconds=(recording.end_time - recording.start_time).total_seconds() if recording.end_time else None,
error_message=recording.error_message
)
for rid, recording in recordings.items()
}
except Exception as e:
self.logger.error(f"Error getting recordings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/storage/stats", response_model=StorageStatsResponse)
async def get_storage_stats():
"""Get storage statistics"""
try:
stats = self.storage_manager.get_storage_statistics()
return StorageStatsResponse(**stats)
except Exception as e:
self.logger.error(f"Error getting storage stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/files", response_model=FileListResponse)
async def get_files(request: FileListRequest):
"""Get list of recording files"""
try:
start_date = None
end_date = None
if request.start_date:
start_date = datetime.fromisoformat(request.start_date)
if request.end_date:
end_date = datetime.fromisoformat(request.end_date)
files = self.storage_manager.get_recording_files(
camera_name=request.camera_name,
start_date=start_date,
end_date=end_date,
limit=request.limit
)
return FileListResponse(
files=files,
total_count=len(files)
)
except Exception as e:
self.logger.error(f"Error getting files: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/cleanup", response_model=CleanupResponse)
async def cleanup_storage(request: CleanupRequest):
"""Clean up old storage files"""
try:
result = self.storage_manager.cleanup_old_files(request.max_age_days)
return CleanupResponse(**result)
except Exception as e:
self.logger.error(f"Error during cleanup: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time updates"""
await self.websocket_manager.connect(websocket)
try:
while True:
# Keep connection alive and handle incoming messages
data = await websocket.receive_text()
# Echo back for now - could implement commands later
await self.websocket_manager.send_personal_message(
{"type": "echo", "data": data}, websocket
)
except WebSocketDisconnect:
self.websocket_manager.disconnect(websocket)
def _setup_event_subscriptions(self):
"""Setup event subscriptions for WebSocket broadcasting"""
def broadcast_event(event: Event):
"""Broadcast event to all WebSocket connections"""
try:
message = {
"type": "event",
"event_type": event.event_type.value,
"source": event.source,
"data": event.data,
"timestamp": event.timestamp.isoformat()
}
# Schedule the broadcast in the event loop thread-safely
if self._event_loop and not self._event_loop.is_closed():
# Use call_soon_threadsafe to schedule the coroutine from another thread
asyncio.run_coroutine_threadsafe(
self.websocket_manager.broadcast(message),
self._event_loop
)
else:
self.logger.debug("Event loop not available for broadcasting")
except Exception as e:
self.logger.error(f"Error broadcasting event: {e}")
# Subscribe to all event types for broadcasting
for event_type in EventType:
self.event_system.subscribe(event_type, broadcast_event)
def start(self) -> bool:
"""Start the API server"""
if self.running:
self.logger.warning("API server is already running")
return True
if not self.config.system.enable_api:
self.logger.info("API server disabled in configuration")
return False
try:
self.logger.info(f"Starting API server on {self.config.system.api_host}:{self.config.system.api_port}")
self.running = True
# Start server in separate thread
self._server_thread = threading.Thread(target=self._run_server, daemon=True)
self._server_thread.start()
return True
except Exception as e:
self.logger.error(f"Error starting API server: {e}")
return False
def stop(self) -> None:
"""Stop the API server"""
if not self.running:
return
self.logger.info("Stopping API server...")
self.running = False
# Note: uvicorn doesn't have a clean way to stop from another thread
# In production, you might want to use a process manager like gunicorn
self.logger.info("API server stopped")
def _run_server(self) -> None:
"""Run the uvicorn server"""
try:
# Capture the event loop for thread-safe event broadcasting
self._event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._event_loop)
uvicorn.run(
self.app,
host=self.config.system.api_host,
port=self.config.system.api_port,
log_level="info"
)
except Exception as e:
self.logger.error(f"Error running API server: {e}")
finally:
self.running = False
self._event_loop = None
def is_running(self) -> bool:
"""Check if API server is running"""
return self.running
def get_server_info(self) -> Dict[str, Any]:
"""Get server information"""
return {
"running": self.running,
"host": self.config.system.api_host,
"port": self.config.system.api_port,
"start_time": self.server_start_time.isoformat(),
"uptime_seconds": (datetime.now() - self.server_start_time).total_seconds(),
"websocket_connections": len(self.websocket_manager.active_connections)
}