427 lines
18 KiB
Python
427 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
|
|
|
|
# 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()
|
|
}
|
|
|
|
# Use asyncio to broadcast (need to handle thread safety)
|
|
asyncio.create_task(self.websocket_manager.broadcast(message))
|
|
|
|
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:
|
|
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
|
|
|
|
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)
|
|
}
|