Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references

This commit is contained in:
Alireza Vaezi
2025-08-07 22:07:25 -04:00
parent 28dab3a366
commit fc2da16728
281 changed files with 19 additions and 19 deletions

View File

@@ -0,0 +1,10 @@
"""
API module for the USDA Vision Camera System.
This module provides REST API endpoints and WebSocket support for dashboard integration.
"""
from .server import APIServer
from .models import *
__all__ = ["APIServer"]

View File

@@ -0,0 +1,336 @@
"""
Data models for the USDA Vision Camera System API.
This module defines Pydantic models for API requests and responses.
"""
from typing import Dict, List, Optional, Any
from datetime import datetime
from pydantic import BaseModel, Field
class SystemStatusResponse(BaseModel):
"""System status response model"""
system_started: bool
mqtt_connected: bool
last_mqtt_message: Optional[str] = None
machines: Dict[str, Dict[str, Any]]
cameras: Dict[str, Dict[str, Any]]
active_recordings: int
total_recordings: int
uptime_seconds: Optional[float] = None
class MachineStatusResponse(BaseModel):
"""Machine status response model"""
name: str
state: str
last_updated: str
last_message: Optional[str] = None
mqtt_topic: Optional[str] = None
class MQTTStatusResponse(BaseModel):
"""MQTT status response model"""
connected: bool
broker_host: str
broker_port: int
subscribed_topics: List[str]
last_message_time: Optional[str] = None
message_count: int
error_count: int
uptime_seconds: Optional[float] = None
class CameraStatusResponse(BaseModel):
"""Camera status response model"""
name: str
status: str
is_recording: bool
last_checked: str
last_error: Optional[str] = None
device_info: Optional[Dict[str, Any]] = None
current_recording_file: Optional[str] = None
recording_start_time: Optional[str] = None
# Auto-recording status
auto_recording_enabled: bool = False
auto_recording_active: bool = False
auto_recording_failure_count: int = 0
auto_recording_last_attempt: Optional[str] = None
auto_recording_last_error: Optional[str] = None
class RecordingInfoResponse(BaseModel):
"""Recording information response model"""
camera_name: str
filename: str
start_time: str
state: str
end_time: Optional[str] = None
file_size_bytes: Optional[int] = None
frame_count: Optional[int] = None
duration_seconds: Optional[float] = None
error_message: Optional[str] = None
class StartRecordingRequest(BaseModel):
"""Start recording request model"""
filename: Optional[str] = None
exposure_ms: Optional[float] = Field(default=None, description="Exposure time in milliseconds")
gain: Optional[float] = Field(default=None, description="Camera gain value")
fps: Optional[float] = Field(default=None, description="Target frames per second")
class CameraConfigRequest(BaseModel):
"""Camera configuration update request model"""
# Basic settings
exposure_ms: Optional[float] = Field(default=None, ge=0.1, le=1000.0, description="Exposure time in milliseconds")
gain: Optional[float] = Field(default=None, ge=0.0, le=20.0, description="Camera gain value")
target_fps: Optional[float] = Field(default=None, ge=0.0, le=120.0, description="Target frames per second")
# Image Quality Settings
sharpness: Optional[int] = Field(default=None, ge=0, le=200, description="Sharpness (0-200, default 100)")
contrast: Optional[int] = Field(default=None, ge=0, le=200, description="Contrast (0-200, default 100)")
saturation: Optional[int] = Field(default=None, ge=0, le=200, description="Saturation (0-200, default 100)")
gamma: Optional[int] = Field(default=None, ge=0, le=300, description="Gamma (0-300, default 100)")
# Noise Reduction
noise_filter_enabled: Optional[bool] = Field(default=None, description="Enable basic noise filtering")
denoise_3d_enabled: Optional[bool] = Field(default=None, description="Enable advanced 3D denoising")
# Color Settings (for color cameras)
auto_white_balance: Optional[bool] = Field(default=None, description="Enable automatic white balance")
color_temperature_preset: Optional[int] = Field(default=None, ge=0, le=10, description="Color temperature preset")
# Manual White Balance RGB Gains
wb_red_gain: Optional[float] = Field(default=None, ge=0.0, le=3.99, description="Red channel gain for manual white balance")
wb_green_gain: Optional[float] = Field(default=None, ge=0.0, le=3.99, description="Green channel gain for manual white balance")
wb_blue_gain: Optional[float] = Field(default=None, ge=0.0, le=3.99, description="Blue channel gain for manual white balance")
# Advanced Settings
anti_flicker_enabled: Optional[bool] = Field(default=None, description="Reduce artificial lighting flicker")
light_frequency: Optional[int] = Field(default=None, ge=0, le=1, description="Light frequency (0=50Hz, 1=60Hz)")
# HDR Settings
hdr_enabled: Optional[bool] = Field(default=None, description="Enable High Dynamic Range")
hdr_gain_mode: Optional[int] = Field(default=None, ge=0, le=3, description="HDR processing mode")
class CameraConfigResponse(BaseModel):
"""Camera configuration response model"""
name: str
machine_topic: str
storage_path: str
enabled: bool
# Auto-recording settings
auto_start_recording_enabled: bool
auto_recording_max_retries: int
auto_recording_retry_delay_seconds: int
# Basic settings
exposure_ms: float
gain: float
target_fps: float
# Video recording settings
video_format: str
video_codec: str
video_quality: int
# Image Quality Settings
sharpness: int
contrast: int
saturation: int
gamma: int
# Noise Reduction
noise_filter_enabled: bool
denoise_3d_enabled: bool
# Color Settings
auto_white_balance: bool
color_temperature_preset: int
# Manual White Balance RGB Gains
wb_red_gain: float
wb_green_gain: float
wb_blue_gain: float
# Advanced Settings
anti_flicker_enabled: bool
light_frequency: int
bit_depth: int
# HDR Settings
hdr_enabled: bool
hdr_gain_mode: int
class StartRecordingResponse(BaseModel):
"""Start recording response model"""
success: bool
message: str
filename: Optional[str] = None
class StopRecordingRequest(BaseModel):
"""Stop recording request model"""
# Note: This model is currently unused as the stop recording endpoint
# only requires the camera_name from the URL path parameter
pass
class StopRecordingResponse(BaseModel):
"""Stop recording response model"""
success: bool
message: str
duration_seconds: Optional[float] = None
class AutoRecordingConfigRequest(BaseModel):
"""Auto-recording configuration request model"""
enabled: bool
class AutoRecordingConfigResponse(BaseModel):
"""Auto-recording configuration response model"""
success: bool
message: str
camera_name: str
enabled: bool
class AutoRecordingStatusResponse(BaseModel):
"""Auto-recording manager status response model"""
running: bool
auto_recording_enabled: bool
retry_queue: Dict[str, Any]
enabled_cameras: List[str]
class StorageStatsResponse(BaseModel):
"""Storage statistics response model"""
base_path: str
total_files: int
total_size_bytes: int
cameras: Dict[str, Dict[str, Any]]
disk_usage: Dict[str, Any]
class FileListRequest(BaseModel):
"""File list request model"""
camera_name: Optional[str] = None
start_date: Optional[str] = None
end_date: Optional[str] = None
limit: Optional[int] = Field(default=100, le=1000)
class FileListResponse(BaseModel):
"""File list response model"""
files: List[Dict[str, Any]]
total_count: int
class CleanupRequest(BaseModel):
"""Cleanup request model"""
max_age_days: Optional[int] = None
class CleanupResponse(BaseModel):
"""Cleanup response model"""
files_removed: int
bytes_freed: int
errors: List[str]
class EventResponse(BaseModel):
"""Event response model"""
event_type: str
source: str
data: Dict[str, Any]
timestamp: str
class WebSocketMessage(BaseModel):
"""WebSocket message model"""
type: str
data: Dict[str, Any]
timestamp: Optional[str] = None
class ErrorResponse(BaseModel):
"""Error response model"""
error: str
details: Optional[str] = None
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class CameraRecoveryResponse(BaseModel):
"""Camera recovery response model"""
success: bool
message: str
camera_name: str
operation: str
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class CameraTestResponse(BaseModel):
"""Camera connection test response model"""
success: bool
message: str
camera_name: str
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class MQTTEventResponse(BaseModel):
"""MQTT event response model"""
machine_name: str
topic: str
payload: str
normalized_state: str
timestamp: str
message_number: int
class MQTTEventsHistoryResponse(BaseModel):
"""MQTT events history response model"""
events: List[MQTTEventResponse]
total_events: int
last_updated: Optional[str] = None
class SuccessResponse(BaseModel):
"""Success response model"""
success: bool = True
message: str
data: Optional[Dict[str, Any]] = None
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())

View File

@@ -0,0 +1,779 @@
"""
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, StreamingResponse
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 ..video.integration import create_video_module, VideoModule
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, auto_recording_manager=None):
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.auto_recording_manager = auto_recording_manager
self.logger = logging.getLogger(__name__)
# Initialize video module
self.video_module: Optional[VideoModule] = None
self._initialize_video_module()
# 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=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) # Configure appropriately for production
# Setup routes
self._setup_routes()
# Subscribe to events for WebSocket broadcasting
self._setup_event_subscriptions()
def _initialize_video_module(self):
"""Initialize the modular video streaming system"""
try:
self.video_module = create_video_module(config=self.config, storage_manager=self.storage_manager, enable_caching=True, enable_conversion=True)
self.logger.info("Video module initialized successfully")
except Exception as e:
self.logger.error(f"Failed to initialize video module: {e}")
self.video_module = None
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("/system/video-module")
async def get_video_module_status():
"""Get video module status and configuration"""
try:
if self.video_module:
status = self.video_module.get_module_status()
status["enabled"] = True
return status
else:
return {"enabled": False, "error": "Video module not initialized"}
except Exception as e:
self.logger.error(f"Error getting video module 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("/mqtt/status", response_model=MQTTStatusResponse)
async def get_mqtt_status():
"""Get MQTT client status and statistics"""
try:
status = self.mqtt_client.get_status()
return MQTTStatusResponse(connected=status["connected"], broker_host=status["broker_host"], broker_port=status["broker_port"], subscribed_topics=status["subscribed_topics"], last_message_time=status["last_message_time"], message_count=status["message_count"], error_count=status["error_count"], uptime_seconds=status["uptime_seconds"])
except Exception as e:
self.logger.error(f"Error getting MQTT status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/mqtt/events", response_model=MQTTEventsHistoryResponse)
async def get_mqtt_events(limit: int = Query(default=5, ge=1, le=50, description="Number of recent events to retrieve")):
"""Get recent MQTT events history"""
try:
events = self.state_manager.get_recent_mqtt_events(limit)
total_events = self.state_manager.get_mqtt_event_count()
# Convert events to response format
event_responses = [MQTTEventResponse(machine_name=event.machine_name, topic=event.topic, payload=event.payload, normalized_state=event.normalized_state, timestamp=event.timestamp.isoformat(), message_number=event.message_number) for event in events]
last_updated = events[0].timestamp.isoformat() if events else None
return MQTTEventsHistoryResponse(events=event_responses, total_events=total_events, last_updated=last_updated)
except Exception as e:
self.logger.error(f"Error getting MQTT events: {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,
auto_recording_enabled=camera.auto_recording_enabled,
auto_recording_active=camera.auto_recording_active,
auto_recording_failure_count=camera.auto_recording_failure_count,
auto_recording_last_attempt=camera.auto_recording_last_attempt.isoformat() if camera.auto_recording_last_attempt else None,
auto_recording_last_error=camera.auto_recording_last_error,
)
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=camera_name, filename=request.filename, exposure_ms=request.exposure_ms, gain=request.gain, fps=request.fps)
if success:
# Get the actual filename that was used (with datetime prefix)
actual_filename = request.filename
if request.filename:
from ..core.timezone_utils import format_filename_timestamp
timestamp = format_filename_timestamp()
actual_filename = f"{timestamp}_{request.filename}"
return StartRecordingResponse(success=True, message=f"Recording started for {camera_name}", filename=actual_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.post("/cameras/{camera_name}/test-connection", response_model=CameraTestResponse)
async def test_camera_connection(camera_name: str):
"""Test camera connection"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.test_camera_connection(camera_name)
if success:
return CameraTestResponse(success=True, message=f"Camera {camera_name} connection test passed", camera_name=camera_name)
else:
return CameraTestResponse(success=False, message=f"Camera {camera_name} connection test failed", camera_name=camera_name)
except Exception as e:
self.logger.error(f"Error testing camera connection: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/stream")
async def camera_stream(camera_name: str):
"""Get live MJPEG stream from camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
# Get camera streamer
streamer = self.camera_manager.get_camera_streamer(camera_name)
if not streamer:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
# Start streaming if not already active
if not streamer.is_streaming():
success = streamer.start_streaming()
if not success:
raise HTTPException(status_code=500, detail=f"Failed to start streaming for camera {camera_name}")
# Return MJPEG stream
return StreamingResponse(streamer.get_frame_generator(), media_type="multipart/x-mixed-replace; boundary=frame")
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error starting camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-stream")
async def start_camera_stream(camera_name: str):
"""Start streaming for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.start_camera_streaming(camera_name)
if success:
return {"success": True, "message": f"Started streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to start streaming for camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error starting camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-stream")
async def stop_camera_stream(camera_name: str):
"""Stop streaming for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.stop_camera_streaming(camera_name)
if success:
return {"success": True, "message": f"Stopped streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to stop streaming for camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error stopping camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/config", response_model=CameraConfigResponse)
async def get_camera_config(camera_name: str):
"""Get camera configuration"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
config = self.camera_manager.get_camera_config(camera_name)
if not config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
return CameraConfigResponse(
name=config.name,
machine_topic=config.machine_topic,
storage_path=config.storage_path,
enabled=config.enabled,
# Auto-recording settings
auto_start_recording_enabled=config.auto_start_recording_enabled,
auto_recording_max_retries=config.auto_recording_max_retries,
auto_recording_retry_delay_seconds=config.auto_recording_retry_delay_seconds,
# Basic settings
exposure_ms=config.exposure_ms,
gain=config.gain,
target_fps=config.target_fps,
# Video recording settings
video_format=config.video_format,
video_codec=config.video_codec,
video_quality=config.video_quality,
# Image Quality Settings
sharpness=config.sharpness,
contrast=config.contrast,
saturation=config.saturation,
gamma=config.gamma,
# Noise Reduction
noise_filter_enabled=config.noise_filter_enabled,
denoise_3d_enabled=config.denoise_3d_enabled,
# Color Settings
auto_white_balance=config.auto_white_balance,
color_temperature_preset=config.color_temperature_preset,
# Manual White Balance RGB Gains
wb_red_gain=config.wb_red_gain,
wb_green_gain=config.wb_green_gain,
wb_blue_gain=config.wb_blue_gain,
# Advanced Settings
anti_flicker_enabled=config.anti_flicker_enabled,
light_frequency=config.light_frequency,
bit_depth=config.bit_depth,
# HDR Settings
hdr_enabled=config.hdr_enabled,
hdr_gain_mode=config.hdr_gain_mode,
)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error getting camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.put("/cameras/{camera_name}/config")
async def update_camera_config(camera_name: str, request: CameraConfigRequest):
"""Update camera configuration"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
# Convert request to dict, excluding None values
config_updates = {k: v for k, v in request.dict().items() if v is not None}
if not config_updates:
raise HTTPException(status_code=400, detail="No configuration updates provided")
success = self.camera_manager.update_camera_config(camera_name, **config_updates)
if success:
return {"success": True, "message": f"Camera {camera_name} configuration updated", "updated_settings": list(config_updates.keys())}
else:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found or update failed")
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error updating camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/apply-config")
async def apply_camera_config(camera_name: str):
"""Apply current configuration to active camera (requires camera restart)"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.apply_camera_config(camera_name)
if success:
return {"success": True, "message": f"Configuration applied to camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to apply configuration to camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error applying camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reconnect", response_model=CameraRecoveryResponse)
async def reconnect_camera(camera_name: str):
"""Reconnect to a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reconnect_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reconnected successfully", camera_name=camera_name, operation="reconnect")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reconnect camera {camera_name}", camera_name=camera_name, operation="reconnect")
except Exception as e:
self.logger.error(f"Error reconnecting camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/restart-grab", response_model=CameraRecoveryResponse)
async def restart_camera_grab(camera_name: str):
"""Restart camera grab process"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.restart_camera_grab(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} grab process restarted successfully", camera_name=camera_name, operation="restart-grab")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to restart grab process for camera {camera_name}", camera_name=camera_name, operation="restart-grab")
except Exception as e:
self.logger.error(f"Error restarting camera grab: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reset-timestamp", response_model=CameraRecoveryResponse)
async def reset_camera_timestamp(camera_name: str):
"""Reset camera timestamp"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reset_camera_timestamp(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} timestamp reset successfully", camera_name=camera_name, operation="reset-timestamp")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reset timestamp for camera {camera_name}", camera_name=camera_name, operation="reset-timestamp")
except Exception as e:
self.logger.error(f"Error resetting camera timestamp: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/full-reset", response_model=CameraRecoveryResponse)
async def full_reset_camera(camera_name: str):
"""Perform full camera reset (uninitialize and reinitialize)"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.full_reset_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} full reset completed successfully", camera_name=camera_name, operation="full-reset")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to perform full reset for camera {camera_name}", camera_name=camera_name, operation="full-reset")
except Exception as e:
self.logger.error(f"Error performing full camera reset: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reinitialize", response_model=CameraRecoveryResponse)
async def reinitialize_camera(camera_name: str):
"""Reinitialize a failed camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reinitialize_failed_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reinitialized successfully", camera_name=camera_name, operation="reinitialize")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reinitialize camera {camera_name}", camera_name=camera_name, operation="reinitialize")
except Exception as e:
self.logger.error(f"Error reinitializing camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/auto-recording/enable", response_model=AutoRecordingConfigResponse)
async def enable_auto_recording(camera_name: str):
"""Enable auto-recording for a camera"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
# Update camera configuration
camera_config = self.config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
camera_config.auto_start_recording_enabled = True
self.config.save_config()
# Update camera status in state manager
camera_info = self.state_manager.get_camera_status(camera_name)
if camera_info:
camera_info.auto_recording_enabled = True
return AutoRecordingConfigResponse(success=True, message=f"Auto-recording enabled for camera {camera_name}", camera_name=camera_name, enabled=True)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error enabling auto-recording for camera {camera_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/auto-recording/disable", response_model=AutoRecordingConfigResponse)
async def disable_auto_recording(camera_name: str):
"""Disable auto-recording for a camera"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
# Update camera configuration
camera_config = self.config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
camera_config.auto_start_recording_enabled = False
self.config.save_config()
# Update camera status in state manager
camera_info = self.state_manager.get_camera_status(camera_name)
if camera_info:
camera_info.auto_recording_enabled = False
camera_info.auto_recording_active = False
return AutoRecordingConfigResponse(success=True, message=f"Auto-recording disabled for camera {camera_name}", camera_name=camera_name, enabled=False)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error disabling auto-recording for camera {camera_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/auto-recording/status", response_model=AutoRecordingStatusResponse)
async def get_auto_recording_status():
"""Get auto-recording manager status"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
status = self.auto_recording_manager.get_status()
return AutoRecordingStatusResponse(**status)
except Exception as e:
self.logger.error(f"Error getting auto-recording status: {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)
# Include video module routes if available
if self.video_module:
try:
video_routes = self.video_module.get_api_routes()
admin_video_routes = self.video_module.get_admin_routes()
self.app.include_router(video_routes)
self.app.include_router(admin_video_routes)
self.logger.info("Video streaming routes added successfully")
except Exception as e:
self.logger.error(f"Failed to add video routes: {e}")
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
# Clean up video module
if self.video_module:
try:
# Note: This is synchronous cleanup - in a real async context you'd await this
asyncio.run(self.video_module.cleanup())
self.logger.info("Video module cleanup completed")
except Exception as e:
self.logger.error(f"Error during video module cleanup: {e}")
# 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)}