Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references
This commit is contained in:
13
camera-management-api/usda_vision_system/__init__.py
Normal file
13
camera-management-api/usda_vision_system/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
USDA Vision Camera System
|
||||
|
||||
A comprehensive system for monitoring machines via MQTT and automatically recording
|
||||
video from GigE cameras when machines are active.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "USDA Vision Team"
|
||||
|
||||
from .main import USDAVisionSystem
|
||||
|
||||
__all__ = ["USDAVisionSystem"]
|
||||
8
camera-management-api/usda_vision_system/__main__.py
Normal file
8
camera-management-api/usda_vision_system/__main__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Entry point for running the USDA Vision Camera System as a module.
|
||||
"""
|
||||
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
camera-management-api/usda_vision_system/api/__init__.py
Normal file
10
camera-management-api/usda_vision_system/api/__init__.py
Normal 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"]
|
||||
336
camera-management-api/usda_vision_system/api/models.py
Normal file
336
camera-management-api/usda_vision_system/api/models.py
Normal 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())
|
||||
779
camera-management-api/usda_vision_system/api/server.py
Normal file
779
camera-management-api/usda_vision_system/api/server.py
Normal 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)}
|
||||
13
camera-management-api/usda_vision_system/camera/__init__.py
Normal file
13
camera-management-api/usda_vision_system/camera/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Camera module for the USDA Vision Camera System.
|
||||
|
||||
This module handles GigE camera discovery, management, monitoring, and recording
|
||||
using the camera SDK library (mvsdk).
|
||||
"""
|
||||
|
||||
from .manager import CameraManager
|
||||
from .recorder import CameraRecorder
|
||||
from .monitor import CameraMonitor
|
||||
from .streamer import CameraStreamer
|
||||
|
||||
__all__ = ["CameraManager", "CameraRecorder", "CameraMonitor", "CameraStreamer"]
|
||||
546
camera-management-api/usda_vision_system/camera/manager.py
Normal file
546
camera-management-api/usda_vision_system/camera/manager.py
Normal file
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
Camera Manager for the USDA Vision Camera System.
|
||||
|
||||
This module manages GigE camera discovery, initialization, and coordination
|
||||
with the recording system based on machine state changes.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from datetime import datetime
|
||||
|
||||
# Add camera SDK to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "camera_sdk"))
|
||||
import mvsdk
|
||||
|
||||
from ..core.config import Config, CameraConfig
|
||||
from ..core.state_manager import StateManager, CameraStatus
|
||||
from ..core.events import EventSystem, EventType, Event, publish_camera_status_changed
|
||||
from ..core.timezone_utils import format_filename_timestamp
|
||||
from .recorder import CameraRecorder
|
||||
from .monitor import CameraMonitor
|
||||
from .streamer import CameraStreamer
|
||||
from .sdk_config import initialize_sdk_with_suppression
|
||||
|
||||
|
||||
class CameraManager:
|
||||
"""Manages all cameras in the system"""
|
||||
|
||||
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem):
|
||||
self.config = config
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize SDK early to suppress error messages
|
||||
initialize_sdk_with_suppression()
|
||||
|
||||
# Camera management
|
||||
self.available_cameras: List[Any] = [] # mvsdk camera device info
|
||||
self.camera_recorders: Dict[str, CameraRecorder] = {} # camera_name -> recorder
|
||||
self.camera_streamers: Dict[str, CameraStreamer] = {} # camera_name -> streamer
|
||||
self.camera_monitor: Optional[CameraMonitor] = None
|
||||
|
||||
# Threading
|
||||
self._lock = threading.RLock()
|
||||
self.running = False
|
||||
|
||||
# Subscribe to machine state changes
|
||||
self.event_system.subscribe(EventType.MACHINE_STATE_CHANGED, self._on_machine_state_changed)
|
||||
|
||||
# Initialize camera discovery
|
||||
self._discover_cameras()
|
||||
|
||||
# Create camera monitor
|
||||
self.camera_monitor = CameraMonitor(config=config, state_manager=state_manager, event_system=event_system, camera_manager=self)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the camera manager"""
|
||||
if self.running:
|
||||
self.logger.warning("Camera manager is already running")
|
||||
return True
|
||||
|
||||
self.logger.info("Starting camera manager...")
|
||||
self.running = True
|
||||
|
||||
# Start camera monitor
|
||||
if self.camera_monitor:
|
||||
self.camera_monitor.start()
|
||||
|
||||
# Initialize camera recorders
|
||||
self._initialize_recorders()
|
||||
|
||||
# Initialize camera streamers
|
||||
self._initialize_streamers()
|
||||
|
||||
self.logger.info("Camera manager started successfully")
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the camera manager"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.logger.info("Stopping camera manager...")
|
||||
self.running = False
|
||||
|
||||
# Stop camera monitor
|
||||
if self.camera_monitor:
|
||||
self.camera_monitor.stop()
|
||||
|
||||
# Stop all active recordings
|
||||
with self._lock:
|
||||
for recorder in self.camera_recorders.values():
|
||||
if recorder.is_recording():
|
||||
recorder.stop_recording()
|
||||
recorder.cleanup()
|
||||
|
||||
# Stop all active streaming
|
||||
with self._lock:
|
||||
for streamer in self.camera_streamers.values():
|
||||
if streamer.is_streaming():
|
||||
streamer.stop_streaming()
|
||||
|
||||
self.logger.info("Camera manager stopped")
|
||||
|
||||
def _discover_cameras(self) -> None:
|
||||
"""Discover available GigE cameras"""
|
||||
try:
|
||||
self.logger.info("Discovering GigE cameras...")
|
||||
|
||||
# Enumerate cameras using mvsdk
|
||||
device_list = mvsdk.CameraEnumerateDevice()
|
||||
self.available_cameras = device_list
|
||||
|
||||
self.logger.info(f"Found {len(device_list)} camera(s)")
|
||||
|
||||
for i, dev_info in enumerate(device_list):
|
||||
try:
|
||||
name = dev_info.GetFriendlyName()
|
||||
port_type = dev_info.GetPortType()
|
||||
serial = getattr(dev_info, "acSn", "Unknown")
|
||||
|
||||
self.logger.info(f" Camera {i}: {name} ({port_type}) - Serial: {serial}")
|
||||
|
||||
# Update state manager with discovered camera
|
||||
camera_name = f"camera{i+1}" # Default naming
|
||||
self.state_manager.update_camera_status(name=camera_name, status="available", device_info={"friendly_name": name, "port_type": port_type, "serial_number": serial, "device_index": i})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing camera {i}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error discovering cameras: {e}")
|
||||
self.available_cameras = []
|
||||
|
||||
def _initialize_recorders(self) -> None:
|
||||
"""Initialize camera recorders for configured cameras"""
|
||||
with self._lock:
|
||||
for camera_config in self.config.cameras:
|
||||
if not camera_config.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Find matching physical camera
|
||||
device_info = self._find_camera_device(camera_config.name)
|
||||
if device_info is None:
|
||||
self.logger.warning(f"No physical camera found for configured camera: {camera_config.name}")
|
||||
# Update state to indicate camera is not available
|
||||
self.state_manager.update_camera_status(name=camera_config.name, status="not_found", device_info=None)
|
||||
continue
|
||||
|
||||
# Create recorder (uses lazy initialization - camera will be initialized when recording starts)
|
||||
recorder = CameraRecorder(camera_config=camera_config, device_info=device_info, state_manager=self.state_manager, event_system=self.event_system)
|
||||
|
||||
# Add recorder to the list (camera will be initialized lazily when needed)
|
||||
self.camera_recorders[camera_config.name] = recorder
|
||||
self.logger.info(f"Successfully created recorder for camera: {camera_config.name} (lazy initialization)")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error initializing recorder for {camera_config.name}: {e}")
|
||||
# Update state to indicate error
|
||||
self.state_manager.update_camera_status(name=camera_config.name, status="error", device_info={"error": str(e)})
|
||||
|
||||
def _find_camera_device(self, camera_name: str) -> Optional[Any]:
|
||||
"""Find physical camera device for a configured camera"""
|
||||
# For now, use simple mapping: camera1 -> device 0, camera2 -> device 1, etc.
|
||||
# This could be enhanced to use serial numbers or other identifiers
|
||||
|
||||
camera_index_map = {"camera1": 0, "camera2": 1, "camera3": 2, "camera4": 3}
|
||||
|
||||
device_index = camera_index_map.get(camera_name)
|
||||
if device_index is not None and device_index < len(self.available_cameras):
|
||||
return self.available_cameras[device_index]
|
||||
|
||||
return None
|
||||
|
||||
def _on_machine_state_changed(self, event: Event) -> None:
|
||||
"""Handle machine state change events"""
|
||||
try:
|
||||
machine_name = event.data.get("machine_name")
|
||||
new_state = event.data.get("state")
|
||||
|
||||
if not machine_name or not new_state:
|
||||
return
|
||||
|
||||
self.logger.info(f"Handling machine state change: {machine_name} -> {new_state}")
|
||||
|
||||
# Find camera associated with this machine
|
||||
camera_config = None
|
||||
for config in self.config.cameras:
|
||||
if config.machine_topic == machine_name:
|
||||
camera_config = config
|
||||
break
|
||||
|
||||
if not camera_config:
|
||||
self.logger.warning(f"No camera configured for machine: {machine_name}")
|
||||
return
|
||||
|
||||
# Get the recorder for this camera
|
||||
recorder = self.camera_recorders.get(camera_config.name)
|
||||
if not recorder:
|
||||
self.logger.warning(f"No recorder found for camera: {camera_config.name}")
|
||||
return
|
||||
|
||||
# Handle state change
|
||||
if new_state == "on":
|
||||
self._start_recording(camera_config.name, recorder)
|
||||
elif new_state in ["off", "error"]:
|
||||
self._stop_recording(camera_config.name, recorder)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling machine state change: {e}")
|
||||
|
||||
def _start_recording(self, camera_name: str, recorder: CameraRecorder) -> None:
|
||||
"""Start recording for a camera"""
|
||||
try:
|
||||
if recorder.is_recording():
|
||||
self.logger.info(f"Camera {camera_name} is already recording")
|
||||
return
|
||||
|
||||
# Generate filename with Atlanta timezone timestamp
|
||||
timestamp = format_filename_timestamp()
|
||||
camera_config = self.config.get_camera_by_name(camera_name)
|
||||
video_format = camera_config.video_format if camera_config else "mp4"
|
||||
filename = f"{camera_name}_recording_{timestamp}.{video_format}"
|
||||
|
||||
# Start recording
|
||||
success = recorder.start_recording(filename)
|
||||
if success:
|
||||
self.logger.info(f"Started recording for camera {camera_name}: {filename}")
|
||||
else:
|
||||
self.logger.error(f"Failed to start recording for camera {camera_name}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error starting recording for {camera_name}: {e}")
|
||||
|
||||
def _stop_recording(self, camera_name: str, recorder: CameraRecorder) -> None:
|
||||
"""Stop recording for a camera"""
|
||||
try:
|
||||
if not recorder.is_recording():
|
||||
self.logger.info(f"Camera {camera_name} is not recording")
|
||||
return
|
||||
|
||||
# Stop recording
|
||||
success = recorder.stop_recording()
|
||||
if success:
|
||||
self.logger.info(f"Stopped recording for camera {camera_name}")
|
||||
else:
|
||||
self.logger.error(f"Failed to stop recording for camera {camera_name}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping recording for {camera_name}: {e}")
|
||||
|
||||
def get_camera_status(self, camera_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get status of a specific camera"""
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
return None
|
||||
|
||||
return recorder.get_status()
|
||||
|
||||
def get_all_camera_status(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get status of all cameras"""
|
||||
status = {}
|
||||
with self._lock:
|
||||
for camera_name, recorder in self.camera_recorders.items():
|
||||
status[camera_name] = recorder.get_status()
|
||||
return status
|
||||
|
||||
def manual_start_recording(self, camera_name: str, filename: Optional[str] = None, exposure_ms: Optional[float] = None, gain: Optional[float] = None, fps: Optional[float] = None) -> bool:
|
||||
"""Manually start recording for a camera with optional camera settings"""
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Camera not found: {camera_name}")
|
||||
return False
|
||||
|
||||
# Update camera settings if provided
|
||||
if exposure_ms is not None or gain is not None or fps is not None:
|
||||
settings_updated = recorder.update_camera_settings(exposure_ms=exposure_ms, gain=gain, target_fps=fps)
|
||||
if not settings_updated:
|
||||
self.logger.warning(f"Failed to update camera settings for {camera_name}")
|
||||
|
||||
# Generate filename with datetime prefix
|
||||
timestamp = format_filename_timestamp()
|
||||
camera_config = self.config.get_camera_by_name(camera_name)
|
||||
video_format = camera_config.video_format if camera_config else "mp4"
|
||||
|
||||
if filename:
|
||||
# Always prepend datetime to the provided filename
|
||||
filename = f"{timestamp}_{filename}"
|
||||
else:
|
||||
filename = f"{camera_name}_manual_{timestamp}.{video_format}"
|
||||
|
||||
return recorder.start_recording(filename)
|
||||
|
||||
def manual_stop_recording(self, camera_name: str) -> bool:
|
||||
"""Manually stop recording for a camera"""
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Camera not found: {camera_name}")
|
||||
return False
|
||||
|
||||
return recorder.stop_recording()
|
||||
|
||||
def get_available_cameras(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of available physical cameras"""
|
||||
cameras = []
|
||||
for i, dev_info in enumerate(self.available_cameras):
|
||||
try:
|
||||
cameras.append({"index": i, "name": dev_info.GetFriendlyName(), "port_type": dev_info.GetPortType(), "serial_number": getattr(dev_info, "acSn", "Unknown")})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting info for camera {i}: {e}")
|
||||
|
||||
return cameras
|
||||
|
||||
def refresh_camera_discovery(self) -> int:
|
||||
"""Refresh camera discovery and return number of cameras found"""
|
||||
self._discover_cameras()
|
||||
return len(self.available_cameras)
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if camera manager is running"""
|
||||
return self.running
|
||||
|
||||
def test_camera_connection(self, camera_name: str) -> bool:
|
||||
"""Test connection for a specific camera"""
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Camera not found: {camera_name}")
|
||||
return False
|
||||
|
||||
return recorder.test_connection()
|
||||
|
||||
def reconnect_camera(self, camera_name: str) -> bool:
|
||||
"""Attempt to reconnect a specific camera"""
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Camera not found: {camera_name}")
|
||||
return False
|
||||
|
||||
success = recorder.reconnect()
|
||||
|
||||
# Update camera status based on result
|
||||
if success:
|
||||
self.state_manager.update_camera_status(name=camera_name, status="connected", error=None)
|
||||
else:
|
||||
self.state_manager.update_camera_status(name=camera_name, status="connection_failed", error="Reconnection failed")
|
||||
|
||||
return success
|
||||
|
||||
def restart_camera_grab(self, camera_name: str) -> bool:
|
||||
"""Restart grab process for a specific camera"""
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Camera not found: {camera_name}")
|
||||
return False
|
||||
|
||||
success = recorder.restart_grab()
|
||||
|
||||
# Update camera status based on result
|
||||
if success:
|
||||
self.state_manager.update_camera_status(name=camera_name, status="connected", error=None)
|
||||
else:
|
||||
self.state_manager.update_camera_status(name=camera_name, status="grab_failed", error="Grab restart failed")
|
||||
|
||||
return success
|
||||
|
||||
def reset_camera_timestamp(self, camera_name: str) -> bool:
|
||||
"""Reset timestamp for a specific camera"""
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Camera not found: {camera_name}")
|
||||
return False
|
||||
|
||||
return recorder.reset_timestamp()
|
||||
|
||||
def full_reset_camera(self, camera_name: str) -> bool:
|
||||
"""Perform full reset for a specific camera"""
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Camera not found: {camera_name}")
|
||||
return False
|
||||
|
||||
success = recorder.full_reset()
|
||||
|
||||
# Update camera status based on result
|
||||
if success:
|
||||
self.state_manager.update_camera_status(name=camera_name, status="connected", error=None)
|
||||
else:
|
||||
self.state_manager.update_camera_status(name=camera_name, status="reset_failed", error="Full reset failed")
|
||||
|
||||
return success
|
||||
|
||||
def reinitialize_failed_camera(self, camera_name: str) -> bool:
|
||||
"""Attempt to reinitialize a camera that failed to initialize"""
|
||||
with self._lock:
|
||||
# Find the camera config
|
||||
camera_config = None
|
||||
for config in self.config.cameras:
|
||||
if config.name == camera_name:
|
||||
camera_config = config
|
||||
break
|
||||
|
||||
if not camera_config:
|
||||
self.logger.error(f"No configuration found for camera: {camera_name}")
|
||||
return False
|
||||
|
||||
if not camera_config.enabled:
|
||||
self.logger.error(f"Camera {camera_name} is disabled in configuration")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Remove existing recorder if any
|
||||
if camera_name in self.camera_recorders:
|
||||
old_recorder = self.camera_recorders[camera_name]
|
||||
try:
|
||||
old_recorder._cleanup_camera()
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
del self.camera_recorders[camera_name]
|
||||
|
||||
# Find matching physical camera
|
||||
device_info = self._find_camera_device(camera_name)
|
||||
if device_info is None:
|
||||
self.logger.warning(f"No physical camera found for configured camera: {camera_name}")
|
||||
self.state_manager.update_camera_status(name=camera_name, status="not_found", device_info=None)
|
||||
return False
|
||||
|
||||
# Create new recorder (uses lazy initialization)
|
||||
recorder = CameraRecorder(camera_config=camera_config, device_info=device_info, state_manager=self.state_manager, event_system=self.event_system)
|
||||
|
||||
# Success - add to recorders (camera will be initialized lazily when needed)
|
||||
self.camera_recorders[camera_name] = recorder
|
||||
self.state_manager.update_camera_status(name=camera_name, status="connected", error=None)
|
||||
|
||||
self.logger.info(f"Successfully reinitialized camera recorder: {camera_name} (lazy initialization)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reinitializing camera {camera_name}: {e}")
|
||||
self.state_manager.update_camera_status(name=camera_name, status="error", device_info={"error": str(e)})
|
||||
return False
|
||||
|
||||
def _initialize_streamers(self) -> None:
|
||||
"""Initialize camera streamers for configured cameras"""
|
||||
with self._lock:
|
||||
for camera_config in self.config.cameras:
|
||||
if not camera_config.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Find matching physical camera
|
||||
device_info = self._find_camera_device(camera_config.name)
|
||||
if device_info is None:
|
||||
self.logger.warning(f"No physical camera found for streaming: {camera_config.name}")
|
||||
continue
|
||||
|
||||
# Create streamer
|
||||
streamer = CameraStreamer(camera_config=camera_config, device_info=device_info, state_manager=self.state_manager, event_system=self.event_system)
|
||||
|
||||
# Add streamer to the list
|
||||
self.camera_streamers[camera_config.name] = streamer
|
||||
self.logger.info(f"Successfully created streamer for camera: {camera_config.name}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error initializing streamer for {camera_config.name}: {e}")
|
||||
|
||||
def get_camera_streamer(self, camera_name: str) -> Optional[CameraStreamer]:
|
||||
"""Get camera streamer for a specific camera"""
|
||||
return self.camera_streamers.get(camera_name)
|
||||
|
||||
def start_camera_streaming(self, camera_name: str) -> bool:
|
||||
"""Start streaming for a specific camera"""
|
||||
streamer = self.camera_streamers.get(camera_name)
|
||||
if not streamer:
|
||||
self.logger.error(f"Camera streamer not found: {camera_name}")
|
||||
return False
|
||||
|
||||
return streamer.start_streaming()
|
||||
|
||||
def stop_camera_streaming(self, camera_name: str) -> bool:
|
||||
"""Stop streaming for a specific camera"""
|
||||
streamer = self.camera_streamers.get(camera_name)
|
||||
if not streamer:
|
||||
self.logger.error(f"Camera streamer not found: {camera_name}")
|
||||
return False
|
||||
|
||||
return streamer.stop_streaming()
|
||||
|
||||
def is_camera_streaming(self, camera_name: str) -> bool:
|
||||
"""Check if a camera is currently streaming"""
|
||||
streamer = self.camera_streamers.get(camera_name)
|
||||
if not streamer:
|
||||
return False
|
||||
|
||||
return streamer.is_streaming()
|
||||
|
||||
def get_camera_config(self, camera_name: str) -> Optional[CameraConfig]:
|
||||
"""Get camera configuration"""
|
||||
return self.config.get_camera_by_name(camera_name)
|
||||
|
||||
def update_camera_config(self, camera_name: str, **kwargs) -> bool:
|
||||
"""Update camera configuration and save to config file"""
|
||||
try:
|
||||
# Update the configuration
|
||||
success = self.config.update_camera_config(camera_name, **kwargs)
|
||||
if success:
|
||||
self.logger.info(f"Updated configuration for camera {camera_name}: {kwargs}")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Failed to update configuration for camera {camera_name}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating camera configuration: {e}")
|
||||
return False
|
||||
|
||||
def apply_camera_config(self, camera_name: str) -> bool:
|
||||
"""Apply current configuration to active camera (requires camera restart)"""
|
||||
try:
|
||||
# Get the recorder for this camera
|
||||
recorder = self.camera_recorders.get(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Camera recorder not found: {camera_name}")
|
||||
return False
|
||||
|
||||
# Stop recording if active
|
||||
was_recording = recorder.is_recording()
|
||||
if was_recording:
|
||||
recorder.stop_recording()
|
||||
|
||||
# Reinitialize the camera with new settings
|
||||
success = self.reinitialize_failed_camera(camera_name)
|
||||
|
||||
if success:
|
||||
self.logger.info(f"Successfully applied configuration to camera {camera_name}")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Failed to apply configuration to camera {camera_name}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error applying camera configuration: {e}")
|
||||
return False
|
||||
256
camera-management-api/usda_vision_system/camera/monitor.py
Normal file
256
camera-management-api/usda_vision_system/camera/monitor.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Camera Monitor for the USDA Vision Camera System.
|
||||
|
||||
This module monitors camera status and availability at regular intervals.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import contextlib
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
# Add camera SDK to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "camera_sdk"))
|
||||
import mvsdk
|
||||
|
||||
from ..core.config import Config
|
||||
from ..core.state_manager import StateManager, CameraStatus
|
||||
from ..core.events import EventSystem, publish_camera_status_changed
|
||||
from .sdk_config import ensure_sdk_initialized
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def suppress_camera_errors():
|
||||
"""Context manager to temporarily suppress camera SDK error output"""
|
||||
# Save original file descriptors
|
||||
original_stderr = os.dup(2)
|
||||
original_stdout = os.dup(1)
|
||||
|
||||
try:
|
||||
# Redirect stderr and stdout to devnull
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull, 2) # stderr
|
||||
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
|
||||
os.close(devnull)
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
# Restore original file descriptors
|
||||
os.dup2(original_stderr, 2)
|
||||
os.dup2(original_stdout, 1)
|
||||
os.close(original_stderr)
|
||||
os.close(original_stdout)
|
||||
|
||||
|
||||
class CameraMonitor:
|
||||
"""Monitors camera status and availability"""
|
||||
|
||||
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager=None):
|
||||
self.config = config
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.camera_manager = camera_manager # Reference to camera manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Monitoring settings
|
||||
self.check_interval = config.system.camera_check_interval_seconds
|
||||
|
||||
# Threading
|
||||
self.running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Status tracking
|
||||
self.last_check_time: Optional[float] = None
|
||||
self.check_count = 0
|
||||
self.error_count = 0
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start camera monitoring"""
|
||||
if self.running:
|
||||
self.logger.warning("Camera monitor is already running")
|
||||
return True
|
||||
|
||||
self.logger.info(f"Starting camera monitor (check interval: {self.check_interval}s)")
|
||||
self.running = True
|
||||
self._stop_event.clear()
|
||||
|
||||
# Start monitoring thread
|
||||
self._thread = threading.Thread(target=self._monitoring_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop camera monitoring"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.logger.info("Stopping camera monitor...")
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
self.logger.info("Camera monitor stopped")
|
||||
|
||||
def _monitoring_loop(self) -> None:
|
||||
"""Main monitoring loop"""
|
||||
self.logger.info("Camera monitoring loop started")
|
||||
|
||||
while self.running and not self._stop_event.is_set():
|
||||
try:
|
||||
self.last_check_time = time.time()
|
||||
self.check_count += 1
|
||||
|
||||
# Check all configured cameras
|
||||
self._check_all_cameras()
|
||||
|
||||
# Wait for next check
|
||||
if self._stop_event.wait(self.check_interval):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
self.error_count += 1
|
||||
self.logger.error(f"Error in camera monitoring loop: {e}")
|
||||
|
||||
# Wait a bit before retrying
|
||||
if self._stop_event.wait(min(self.check_interval, 10)):
|
||||
break
|
||||
|
||||
self.logger.info("Camera monitoring loop ended")
|
||||
|
||||
def _check_all_cameras(self) -> None:
|
||||
"""Check status of all configured cameras"""
|
||||
for camera_config in self.config.cameras:
|
||||
if not camera_config.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
self._check_camera_status(camera_config.name)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking camera {camera_config.name}: {e}")
|
||||
|
||||
def _check_camera_status(self, camera_name: str) -> None:
|
||||
"""Check status of a specific camera"""
|
||||
try:
|
||||
# Get current status from state manager
|
||||
current_info = self.state_manager.get_camera_status(camera_name)
|
||||
|
||||
# Perform actual camera check
|
||||
status, details, device_info = self._perform_camera_check(camera_name)
|
||||
|
||||
# Update state if changed
|
||||
old_status = current_info.status.value if current_info else "unknown"
|
||||
if old_status != status:
|
||||
self.state_manager.update_camera_status(name=camera_name, status=status, error=details if status == "error" else None, device_info=device_info)
|
||||
|
||||
# Publish status change event
|
||||
publish_camera_status_changed(camera_name=camera_name, status=status, details=details)
|
||||
|
||||
self.logger.info(f"Camera {camera_name} status changed: {old_status} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking camera {camera_name}: {e}")
|
||||
|
||||
# Update to error state
|
||||
self.state_manager.update_camera_status(name=camera_name, status="error", error=str(e))
|
||||
|
||||
def _perform_camera_check(self, camera_name: str) -> tuple[str, str, Optional[Dict[str, Any]]]:
|
||||
"""Perform actual camera availability check"""
|
||||
try:
|
||||
# Get camera device info from camera manager
|
||||
if not self.camera_manager:
|
||||
return "error", "Camera manager not available", None
|
||||
|
||||
device_info = self.camera_manager._find_camera_device(camera_name)
|
||||
if not device_info:
|
||||
return "disconnected", "Camera device not found", None
|
||||
|
||||
# Check if camera is already opened by another process
|
||||
if mvsdk.CameraIsOpened(device_info):
|
||||
# Camera is opened - check if it's our recorder that's currently recording
|
||||
recorder = self.camera_manager.camera_recorders.get(camera_name)
|
||||
if recorder and recorder.hCamera and recorder.recording:
|
||||
return "available", "Camera recording (in use by system)", self._get_device_info_dict(device_info)
|
||||
else:
|
||||
return "busy", "Camera opened by another process", self._get_device_info_dict(device_info)
|
||||
|
||||
# Try to initialize camera briefly to test availability
|
||||
try:
|
||||
# Ensure SDK is initialized
|
||||
ensure_sdk_initialized()
|
||||
|
||||
# Suppress output to avoid MVCAMAPI error messages during camera testing
|
||||
with suppress_camera_errors():
|
||||
hCamera = mvsdk.CameraInit(device_info, -1, -1)
|
||||
|
||||
# Quick test - try to get one frame
|
||||
try:
|
||||
mvsdk.CameraSetTriggerMode(hCamera, 0)
|
||||
mvsdk.CameraPlay(hCamera)
|
||||
|
||||
# Try to capture with short timeout
|
||||
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(hCamera, 500)
|
||||
mvsdk.CameraReleaseImageBuffer(hCamera, pRawData)
|
||||
|
||||
# Success - camera is available
|
||||
mvsdk.CameraUnInit(hCamera)
|
||||
return "available", "Camera test successful", self._get_device_info_dict(device_info)
|
||||
|
||||
except mvsdk.CameraException as e:
|
||||
mvsdk.CameraUnInit(hCamera)
|
||||
if e.error_code == mvsdk.CAMERA_STATUS_TIME_OUT:
|
||||
return "available", "Camera available but slow response", self._get_device_info_dict(device_info)
|
||||
else:
|
||||
return "error", f"Camera test failed: {e.message}", self._get_device_info_dict(device_info)
|
||||
|
||||
except mvsdk.CameraException as e:
|
||||
return "error", f"Camera initialization failed: {e.message}", self._get_device_info_dict(device_info)
|
||||
|
||||
except Exception as e:
|
||||
return "error", f"Camera check failed: {str(e)}", None
|
||||
|
||||
def _get_device_info_dict(self, device_info) -> Dict[str, Any]:
|
||||
"""Convert device info to dictionary"""
|
||||
try:
|
||||
return {"friendly_name": device_info.GetFriendlyName(), "port_type": device_info.GetPortType(), "serial_number": getattr(device_info, "acSn", "Unknown"), "last_checked": time.time()}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting device info: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def check_camera_now(self, camera_name: str) -> Dict[str, Any]:
|
||||
"""Manually check a specific camera status"""
|
||||
try:
|
||||
status, details, device_info = self._perform_camera_check(camera_name)
|
||||
|
||||
# Update state
|
||||
self.state_manager.update_camera_status(name=camera_name, status=status, error=details if status == "error" else None, device_info=device_info)
|
||||
|
||||
return {"camera_name": camera_name, "status": status, "details": details, "device_info": device_info, "check_time": time.time()}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Manual camera check failed: {e}"
|
||||
self.logger.error(error_msg)
|
||||
return {"camera_name": camera_name, "status": "error", "details": error_msg, "device_info": None, "check_time": time.time()}
|
||||
|
||||
def check_all_cameras_now(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Manually check all cameras"""
|
||||
results = {}
|
||||
for camera_config in self.config.cameras:
|
||||
if camera_config.enabled:
|
||||
results[camera_config.name] = self.check_camera_now(camera_config.name)
|
||||
return results
|
||||
|
||||
def get_monitoring_stats(self) -> Dict[str, Any]:
|
||||
"""Get monitoring statistics"""
|
||||
return {"running": self.running, "check_interval_seconds": self.check_interval, "total_checks": self.check_count, "error_count": self.error_count, "last_check_time": self.last_check_time, "success_rate": (self.check_count - self.error_count) / max(self.check_count, 1) * 100}
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if monitor is running"""
|
||||
return self.running
|
||||
915
camera-management-api/usda_vision_system/camera/recorder.py
Normal file
915
camera-management-api/usda_vision_system/camera/recorder.py
Normal file
@@ -0,0 +1,915 @@
|
||||
"""
|
||||
Camera Recorder for the USDA Vision Camera System.
|
||||
|
||||
This module handles video recording from GigE cameras using the camera SDK library (mvsdk).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import cv2
|
||||
import numpy as np
|
||||
import contextlib
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add camera SDK to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "camera_sdk"))
|
||||
import mvsdk
|
||||
|
||||
from ..core.config import CameraConfig
|
||||
from ..core.state_manager import StateManager
|
||||
from ..core.events import EventSystem, publish_recording_started, publish_recording_stopped, publish_recording_error
|
||||
from ..core.timezone_utils import now_atlanta, format_filename_timestamp
|
||||
from .sdk_config import ensure_sdk_initialized
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def suppress_camera_errors():
|
||||
"""Context manager to temporarily suppress camera SDK error output"""
|
||||
# Save original file descriptors
|
||||
original_stderr = os.dup(2)
|
||||
original_stdout = os.dup(1)
|
||||
|
||||
try:
|
||||
# Redirect stderr and stdout to devnull
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull, 2) # stderr
|
||||
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
|
||||
os.close(devnull)
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
# Restore original file descriptors
|
||||
os.dup2(original_stderr, 2)
|
||||
os.dup2(original_stdout, 1)
|
||||
os.close(original_stderr)
|
||||
os.close(original_stdout)
|
||||
|
||||
|
||||
class CameraRecorder:
|
||||
"""Handles video recording for a single camera"""
|
||||
|
||||
def __init__(self, camera_config: CameraConfig, device_info: Any, state_manager: StateManager, event_system: EventSystem, storage_manager=None):
|
||||
self.camera_config = camera_config
|
||||
self.device_info = device_info
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.storage_manager = storage_manager
|
||||
self.logger = logging.getLogger(f"{__name__}.{camera_config.name}")
|
||||
|
||||
# Camera handle and properties
|
||||
self.hCamera: Optional[int] = None
|
||||
self.cap = None
|
||||
self.monoCamera = False
|
||||
self.frame_buffer = None
|
||||
self.frame_buffer_size = 0
|
||||
|
||||
# Recording state
|
||||
self.recording = False
|
||||
self.video_writer: Optional[cv2.VideoWriter] = None
|
||||
self.output_filename: Optional[str] = None
|
||||
self.frame_count = 0
|
||||
self.start_time: Optional[datetime] = None
|
||||
|
||||
# Threading
|
||||
self._recording_thread: Optional[threading.Thread] = None
|
||||
self._stop_recording_event = threading.Event()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Don't initialize camera immediately - use lazy initialization
|
||||
# Camera will be initialized when recording starts
|
||||
self.logger.info(f"Camera recorder created for: {self.camera_config.name} (lazy initialization)")
|
||||
|
||||
def _initialize_camera(self) -> bool:
|
||||
"""Initialize the camera with configured settings"""
|
||||
try:
|
||||
self.logger.info(f"Initializing camera: {self.camera_config.name}")
|
||||
|
||||
# Ensure SDK is initialized
|
||||
ensure_sdk_initialized()
|
||||
|
||||
# Check if device_info is valid
|
||||
if self.device_info is None:
|
||||
self.logger.error("No device info provided for camera initialization")
|
||||
return False
|
||||
|
||||
# Initialize camera (suppress output to avoid MVCAMAPI error messages)
|
||||
with suppress_camera_errors():
|
||||
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
|
||||
self.logger.info("Camera initialized successfully")
|
||||
|
||||
# Get camera capabilities
|
||||
self.cap = mvsdk.CameraGetCapability(self.hCamera)
|
||||
self.monoCamera = self.cap.sIspCapacity.bMonoSensor != 0
|
||||
self.logger.info(f"Camera type: {'Monochrome' if self.monoCamera else 'Color'}")
|
||||
|
||||
# Set output format based on bit depth configuration
|
||||
if self.monoCamera:
|
||||
if self.camera_config.bit_depth == 16:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO16)
|
||||
elif self.camera_config.bit_depth == 12:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO12)
|
||||
elif self.camera_config.bit_depth == 10:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO10)
|
||||
else: # Default to 8-bit
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8)
|
||||
else:
|
||||
if self.camera_config.bit_depth == 16:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_RGB16)
|
||||
elif self.camera_config.bit_depth == 12:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR12)
|
||||
elif self.camera_config.bit_depth == 10:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR10)
|
||||
else: # Default to 8-bit
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8)
|
||||
|
||||
self.logger.info(f"Output format set to {self.camera_config.bit_depth}-bit {'mono' if self.monoCamera else 'color'}")
|
||||
|
||||
# Configure camera settings
|
||||
self._configure_camera_settings()
|
||||
|
||||
# Allocate frame buffer based on bit depth
|
||||
bytes_per_pixel = self._get_bytes_per_pixel()
|
||||
self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
|
||||
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
|
||||
|
||||
# Start camera
|
||||
mvsdk.CameraPlay(self.hCamera)
|
||||
self.logger.info("Camera started successfully")
|
||||
|
||||
return True
|
||||
|
||||
except mvsdk.CameraException as e:
|
||||
error_msg = f"Camera initialization failed({e.error_code}): {e.message}"
|
||||
if e.error_code == 32774:
|
||||
error_msg += " - This may indicate the camera is already in use by another process or there's a resource conflict"
|
||||
self.logger.error(error_msg)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error during camera initialization: {e}")
|
||||
return False
|
||||
|
||||
def _get_bytes_per_pixel(self) -> int:
|
||||
"""Calculate bytes per pixel based on camera type and bit depth"""
|
||||
if self.monoCamera:
|
||||
# Monochrome camera
|
||||
if self.camera_config.bit_depth >= 16:
|
||||
return 2 # 16-bit mono
|
||||
elif self.camera_config.bit_depth >= 12:
|
||||
return 2 # 12-bit mono (stored in 16-bit)
|
||||
elif self.camera_config.bit_depth >= 10:
|
||||
return 2 # 10-bit mono (stored in 16-bit)
|
||||
else:
|
||||
return 1 # 8-bit mono
|
||||
else:
|
||||
# Color camera
|
||||
if self.camera_config.bit_depth >= 16:
|
||||
return 6 # 16-bit RGB (2 bytes × 3 channels)
|
||||
elif self.camera_config.bit_depth >= 12:
|
||||
return 6 # 12-bit RGB (stored as 16-bit)
|
||||
elif self.camera_config.bit_depth >= 10:
|
||||
return 6 # 10-bit RGB (stored as 16-bit)
|
||||
else:
|
||||
return 3 # 8-bit RGB
|
||||
|
||||
def _configure_camera_settings(self) -> None:
|
||||
"""Configure camera settings from config"""
|
||||
try:
|
||||
# Set trigger mode (continuous acquisition)
|
||||
mvsdk.CameraSetTriggerMode(self.hCamera, 0)
|
||||
|
||||
# Set manual exposure
|
||||
mvsdk.CameraSetAeState(self.hCamera, 0) # Disable auto exposure
|
||||
exposure_us = int(self.camera_config.exposure_ms * 1000) # Convert ms to microseconds
|
||||
mvsdk.CameraSetExposureTime(self.hCamera, exposure_us)
|
||||
|
||||
# Set analog gain
|
||||
gain_value = int(self.camera_config.gain * 100) # Convert to camera units
|
||||
mvsdk.CameraSetAnalogGain(self.hCamera, gain_value)
|
||||
|
||||
# Configure image quality settings
|
||||
self._configure_image_quality()
|
||||
|
||||
# Configure noise reduction
|
||||
self._configure_noise_reduction()
|
||||
|
||||
# Configure color settings (for color cameras)
|
||||
if not self.monoCamera:
|
||||
self._configure_color_settings()
|
||||
|
||||
# Configure advanced settings
|
||||
self._configure_advanced_settings()
|
||||
|
||||
self.logger.info(f"Camera settings configured - Exposure: {exposure_us}μs, Gain: {gain_value}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring camera settings: {e}")
|
||||
|
||||
def _configure_image_quality(self) -> None:
|
||||
"""Configure image quality settings"""
|
||||
try:
|
||||
# Set sharpness (0-200, default 100)
|
||||
mvsdk.CameraSetSharpness(self.hCamera, self.camera_config.sharpness)
|
||||
|
||||
# Set contrast (0-200, default 100)
|
||||
mvsdk.CameraSetContrast(self.hCamera, self.camera_config.contrast)
|
||||
|
||||
# Set gamma (0-300, default 100)
|
||||
mvsdk.CameraSetGamma(self.hCamera, self.camera_config.gamma)
|
||||
|
||||
# Set saturation for color cameras (0-200, default 100)
|
||||
if not self.monoCamera:
|
||||
mvsdk.CameraSetSaturation(self.hCamera, self.camera_config.saturation)
|
||||
|
||||
self.logger.info(f"Image quality configured - Sharpness: {self.camera_config.sharpness}, " f"Contrast: {self.camera_config.contrast}, Gamma: {self.camera_config.gamma}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring image quality: {e}")
|
||||
|
||||
def _configure_noise_reduction(self) -> None:
|
||||
"""Configure noise reduction settings"""
|
||||
try:
|
||||
# Enable/disable basic noise filter
|
||||
mvsdk.CameraSetNoiseFilter(self.hCamera, self.camera_config.noise_filter_enabled)
|
||||
|
||||
# Configure 3D denoising if enabled
|
||||
if self.camera_config.denoise_3d_enabled:
|
||||
# Enable 3D denoising with default parameters (3 frames, equal weights)
|
||||
mvsdk.CameraSetDenoise3DParams(self.hCamera, True, 3, None)
|
||||
self.logger.info("3D denoising enabled")
|
||||
else:
|
||||
mvsdk.CameraSetDenoise3DParams(self.hCamera, False, 2, None)
|
||||
|
||||
self.logger.info(f"Noise reduction configured - Filter: {self.camera_config.noise_filter_enabled}, " f"3D Denoise: {self.camera_config.denoise_3d_enabled}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring noise reduction: {e}")
|
||||
|
||||
def _configure_color_settings(self) -> None:
|
||||
"""Configure color settings for color cameras"""
|
||||
try:
|
||||
# Set white balance mode
|
||||
mvsdk.CameraSetWbMode(self.hCamera, self.camera_config.auto_white_balance)
|
||||
|
||||
# Set color temperature preset if not using auto white balance
|
||||
if not self.camera_config.auto_white_balance:
|
||||
mvsdk.CameraSetPresetClrTemp(self.hCamera, self.camera_config.color_temperature_preset)
|
||||
|
||||
# Set manual RGB gains for manual white balance
|
||||
red_gain = int(self.camera_config.wb_red_gain * 100) # Convert to camera units
|
||||
green_gain = int(self.camera_config.wb_green_gain * 100)
|
||||
blue_gain = int(self.camera_config.wb_blue_gain * 100)
|
||||
mvsdk.CameraSetUserClrTempGain(self.hCamera, red_gain, green_gain, blue_gain)
|
||||
|
||||
self.logger.info(f"Color settings configured - Auto WB: {self.camera_config.auto_white_balance}, " f"Color Temp Preset: {self.camera_config.color_temperature_preset}, " f"RGB Gains: R={self.camera_config.wb_red_gain}, G={self.camera_config.wb_green_gain}, B={self.camera_config.wb_blue_gain}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring color settings: {e}")
|
||||
|
||||
def _configure_advanced_settings(self) -> None:
|
||||
"""Configure advanced camera settings"""
|
||||
try:
|
||||
# Set anti-flicker
|
||||
mvsdk.CameraSetAntiFlick(self.hCamera, self.camera_config.anti_flicker_enabled)
|
||||
|
||||
# Set light frequency (0=50Hz, 1=60Hz)
|
||||
mvsdk.CameraSetLightFrequency(self.hCamera, self.camera_config.light_frequency)
|
||||
|
||||
# Configure HDR if enabled (check if HDR functions are available)
|
||||
try:
|
||||
if self.camera_config.hdr_enabled:
|
||||
mvsdk.CameraSetHDR(self.hCamera, 1) # Enable HDR
|
||||
mvsdk.CameraSetHDRGainMode(self.hCamera, self.camera_config.hdr_gain_mode)
|
||||
self.logger.info(f"HDR enabled with gain mode: {self.camera_config.hdr_gain_mode}")
|
||||
else:
|
||||
mvsdk.CameraSetHDR(self.hCamera, 0) # Disable HDR
|
||||
except AttributeError:
|
||||
self.logger.info("HDR functions not available in this SDK version, skipping HDR configuration")
|
||||
|
||||
self.logger.info(f"Advanced settings configured - Anti-flicker: {self.camera_config.anti_flicker_enabled}, " f"Light Freq: {self.camera_config.light_frequency}Hz, HDR: {self.camera_config.hdr_enabled}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring advanced settings: {e}")
|
||||
|
||||
def update_camera_settings(self, exposure_ms: Optional[float] = None, gain: Optional[float] = None, target_fps: Optional[float] = None) -> bool:
|
||||
"""Update camera settings dynamically"""
|
||||
if not self.hCamera:
|
||||
self.logger.error("Camera not initialized")
|
||||
return False
|
||||
|
||||
try:
|
||||
settings_updated = False
|
||||
|
||||
# Update exposure if provided
|
||||
if exposure_ms is not None:
|
||||
mvsdk.CameraSetAeState(self.hCamera, 0) # Disable auto exposure
|
||||
exposure_us = int(exposure_ms * 1000) # Convert ms to microseconds
|
||||
mvsdk.CameraSetExposureTime(self.hCamera, exposure_us)
|
||||
self.camera_config.exposure_ms = exposure_ms
|
||||
self.logger.info(f"Updated exposure time: {exposure_ms}ms")
|
||||
settings_updated = True
|
||||
|
||||
# Update gain if provided
|
||||
if gain is not None:
|
||||
gain_value = int(gain * 100) # Convert to camera units
|
||||
mvsdk.CameraSetAnalogGain(self.hCamera, gain_value)
|
||||
self.camera_config.gain = gain
|
||||
self.logger.info(f"Updated gain: {gain}x")
|
||||
settings_updated = True
|
||||
|
||||
# Update target FPS if provided
|
||||
if target_fps is not None:
|
||||
self.camera_config.target_fps = target_fps
|
||||
self.logger.info(f"Updated target FPS: {target_fps}")
|
||||
settings_updated = True
|
||||
|
||||
return settings_updated
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating camera settings: {e}")
|
||||
return False
|
||||
|
||||
def update_advanced_camera_settings(self, **kwargs) -> bool:
|
||||
"""Update advanced camera settings dynamically"""
|
||||
if not self.hCamera:
|
||||
self.logger.error("Camera not initialized")
|
||||
return False
|
||||
|
||||
try:
|
||||
settings_updated = False
|
||||
|
||||
# Update basic settings
|
||||
if "exposure_ms" in kwargs and kwargs["exposure_ms"] is not None:
|
||||
mvsdk.CameraSetAeState(self.hCamera, 0)
|
||||
exposure_us = int(kwargs["exposure_ms"] * 1000)
|
||||
mvsdk.CameraSetExposureTime(self.hCamera, exposure_us)
|
||||
self.camera_config.exposure_ms = kwargs["exposure_ms"]
|
||||
settings_updated = True
|
||||
|
||||
if "gain" in kwargs and kwargs["gain"] is not None:
|
||||
gain_value = int(kwargs["gain"] * 100)
|
||||
mvsdk.CameraSetAnalogGain(self.hCamera, gain_value)
|
||||
self.camera_config.gain = kwargs["gain"]
|
||||
settings_updated = True
|
||||
|
||||
if "target_fps" in kwargs and kwargs["target_fps"] is not None:
|
||||
self.camera_config.target_fps = kwargs["target_fps"]
|
||||
settings_updated = True
|
||||
|
||||
# Update image quality settings
|
||||
if "sharpness" in kwargs and kwargs["sharpness"] is not None:
|
||||
mvsdk.CameraSetSharpness(self.hCamera, kwargs["sharpness"])
|
||||
self.camera_config.sharpness = kwargs["sharpness"]
|
||||
settings_updated = True
|
||||
|
||||
if "contrast" in kwargs and kwargs["contrast"] is not None:
|
||||
mvsdk.CameraSetContrast(self.hCamera, kwargs["contrast"])
|
||||
self.camera_config.contrast = kwargs["contrast"]
|
||||
settings_updated = True
|
||||
|
||||
if "gamma" in kwargs and kwargs["gamma"] is not None:
|
||||
mvsdk.CameraSetGamma(self.hCamera, kwargs["gamma"])
|
||||
self.camera_config.gamma = kwargs["gamma"]
|
||||
settings_updated = True
|
||||
|
||||
if "saturation" in kwargs and kwargs["saturation"] is not None and not self.monoCamera:
|
||||
mvsdk.CameraSetSaturation(self.hCamera, kwargs["saturation"])
|
||||
self.camera_config.saturation = kwargs["saturation"]
|
||||
settings_updated = True
|
||||
|
||||
# Update noise reduction settings
|
||||
if "noise_filter_enabled" in kwargs and kwargs["noise_filter_enabled"] is not None:
|
||||
# Note: Noise filter settings may require camera restart to take effect
|
||||
self.camera_config.noise_filter_enabled = kwargs["noise_filter_enabled"]
|
||||
settings_updated = True
|
||||
|
||||
if "denoise_3d_enabled" in kwargs and kwargs["denoise_3d_enabled"] is not None:
|
||||
# Note: 3D denoise settings may require camera restart to take effect
|
||||
self.camera_config.denoise_3d_enabled = kwargs["denoise_3d_enabled"]
|
||||
settings_updated = True
|
||||
|
||||
# Update color settings (for color cameras)
|
||||
if not self.monoCamera:
|
||||
if "auto_white_balance" in kwargs and kwargs["auto_white_balance"] is not None:
|
||||
mvsdk.CameraSetWbMode(self.hCamera, kwargs["auto_white_balance"])
|
||||
self.camera_config.auto_white_balance = kwargs["auto_white_balance"]
|
||||
settings_updated = True
|
||||
|
||||
if "color_temperature_preset" in kwargs and kwargs["color_temperature_preset"] is not None:
|
||||
if not self.camera_config.auto_white_balance:
|
||||
mvsdk.CameraSetPresetClrTemp(self.hCamera, kwargs["color_temperature_preset"])
|
||||
self.camera_config.color_temperature_preset = kwargs["color_temperature_preset"]
|
||||
settings_updated = True
|
||||
|
||||
# Update RGB gains for manual white balance
|
||||
rgb_gains_updated = False
|
||||
if "wb_red_gain" in kwargs and kwargs["wb_red_gain"] is not None:
|
||||
self.camera_config.wb_red_gain = kwargs["wb_red_gain"]
|
||||
rgb_gains_updated = True
|
||||
settings_updated = True
|
||||
|
||||
if "wb_green_gain" in kwargs and kwargs["wb_green_gain"] is not None:
|
||||
self.camera_config.wb_green_gain = kwargs["wb_green_gain"]
|
||||
rgb_gains_updated = True
|
||||
settings_updated = True
|
||||
|
||||
if "wb_blue_gain" in kwargs and kwargs["wb_blue_gain"] is not None:
|
||||
self.camera_config.wb_blue_gain = kwargs["wb_blue_gain"]
|
||||
rgb_gains_updated = True
|
||||
settings_updated = True
|
||||
|
||||
# Apply RGB gains if any were updated and we're in manual white balance mode
|
||||
if rgb_gains_updated and not self.camera_config.auto_white_balance:
|
||||
red_gain = int(self.camera_config.wb_red_gain * 100)
|
||||
green_gain = int(self.camera_config.wb_green_gain * 100)
|
||||
blue_gain = int(self.camera_config.wb_blue_gain * 100)
|
||||
mvsdk.CameraSetUserClrTempGain(self.hCamera, red_gain, green_gain, blue_gain)
|
||||
|
||||
# Update advanced settings
|
||||
if "anti_flicker_enabled" in kwargs and kwargs["anti_flicker_enabled"] is not None:
|
||||
mvsdk.CameraSetAntiFlick(self.hCamera, kwargs["anti_flicker_enabled"])
|
||||
self.camera_config.anti_flicker_enabled = kwargs["anti_flicker_enabled"]
|
||||
settings_updated = True
|
||||
|
||||
if "light_frequency" in kwargs and kwargs["light_frequency"] is not None:
|
||||
mvsdk.CameraSetLightFrequency(self.hCamera, kwargs["light_frequency"])
|
||||
self.camera_config.light_frequency = kwargs["light_frequency"]
|
||||
settings_updated = True
|
||||
|
||||
# Update HDR settings (if supported)
|
||||
if "hdr_enabled" in kwargs and kwargs["hdr_enabled"] is not None:
|
||||
try:
|
||||
mvsdk.CameraSetHDR(self.hCamera, 1 if kwargs["hdr_enabled"] else 0)
|
||||
self.camera_config.hdr_enabled = kwargs["hdr_enabled"]
|
||||
settings_updated = True
|
||||
except AttributeError:
|
||||
self.logger.warning("HDR functions not available in this SDK version")
|
||||
|
||||
if "hdr_gain_mode" in kwargs and kwargs["hdr_gain_mode"] is not None:
|
||||
try:
|
||||
if self.camera_config.hdr_enabled:
|
||||
mvsdk.CameraSetHDRGainMode(self.hCamera, kwargs["hdr_gain_mode"])
|
||||
self.camera_config.hdr_gain_mode = kwargs["hdr_gain_mode"]
|
||||
settings_updated = True
|
||||
except AttributeError:
|
||||
self.logger.warning("HDR gain mode functions not available in this SDK version")
|
||||
|
||||
if settings_updated:
|
||||
updated_settings = [k for k, v in kwargs.items() if v is not None]
|
||||
self.logger.info(f"Updated camera settings: {updated_settings}")
|
||||
|
||||
return settings_updated
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating advanced camera settings: {e}")
|
||||
return False
|
||||
|
||||
def start_recording(self, filename: str) -> bool:
|
||||
"""Start video recording"""
|
||||
with self._lock:
|
||||
if self.recording:
|
||||
self.logger.warning("Already recording!")
|
||||
return False
|
||||
|
||||
# Initialize camera if not already initialized (lazy initialization)
|
||||
if not self.hCamera:
|
||||
self.logger.info("Camera not initialized, initializing now...")
|
||||
if not self._initialize_camera():
|
||||
self.logger.error("Failed to initialize camera for recording")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Prepare output path
|
||||
output_path = os.path.join(self.camera_config.storage_path, filename)
|
||||
Path(self.camera_config.storage_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Test camera capture before starting recording
|
||||
if not self._test_camera_capture():
|
||||
self.logger.error("Camera capture test failed")
|
||||
return False
|
||||
|
||||
# Initialize recording state
|
||||
self.output_filename = output_path
|
||||
self.frame_count = 0
|
||||
self.start_time = now_atlanta() # Use Atlanta timezone
|
||||
self._stop_recording_event.clear()
|
||||
|
||||
# Start recording thread
|
||||
self._recording_thread = threading.Thread(target=self._recording_loop, daemon=True)
|
||||
self._recording_thread.start()
|
||||
|
||||
# Update state
|
||||
self.recording = True
|
||||
recording_id = self.state_manager.start_recording(self.camera_config.name, output_path)
|
||||
|
||||
# Publish event
|
||||
publish_recording_started(self.camera_config.name, output_path)
|
||||
|
||||
self.logger.info(f"Started recording to: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error starting recording: {e}")
|
||||
publish_recording_error(self.camera_config.name, str(e))
|
||||
return False
|
||||
|
||||
def _test_camera_capture(self) -> bool:
|
||||
"""Test if camera can capture frames"""
|
||||
try:
|
||||
# Try to capture one frame
|
||||
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 1000) # 1 second timeout
|
||||
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
|
||||
mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Camera capture test failed: {e}")
|
||||
return False
|
||||
|
||||
def stop_recording(self) -> bool:
|
||||
"""Stop video recording"""
|
||||
with self._lock:
|
||||
if not self.recording:
|
||||
self.logger.warning("Not currently recording")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Signal recording thread to stop
|
||||
self._stop_recording_event.set()
|
||||
|
||||
# Wait for recording thread to finish
|
||||
if self._recording_thread and self._recording_thread.is_alive():
|
||||
self._recording_thread.join(timeout=5)
|
||||
|
||||
# Update state
|
||||
self.recording = False
|
||||
|
||||
# Calculate duration and file size
|
||||
duration = 0
|
||||
file_size = 0
|
||||
if self.start_time:
|
||||
duration = (now_atlanta() - self.start_time).total_seconds()
|
||||
|
||||
if self.output_filename and os.path.exists(self.output_filename):
|
||||
file_size = os.path.getsize(self.output_filename)
|
||||
|
||||
# Update state manager
|
||||
if self.output_filename:
|
||||
self.state_manager.stop_recording(self.output_filename, file_size, self.frame_count)
|
||||
|
||||
# Publish event
|
||||
publish_recording_stopped(self.camera_config.name, self.output_filename or "unknown", duration)
|
||||
|
||||
# Clean up camera resources after recording (lazy cleanup)
|
||||
self._cleanup_camera()
|
||||
self.logger.info("Camera resources cleaned up after recording")
|
||||
|
||||
self.logger.info(f"Stopped recording - Duration: {duration:.1f}s, Frames: {self.frame_count}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping recording: {e}")
|
||||
return False
|
||||
|
||||
def _recording_loop(self) -> None:
|
||||
"""Main recording loop running in separate thread"""
|
||||
try:
|
||||
# Initialize video writer
|
||||
if not self._initialize_video_writer():
|
||||
self.logger.error("Failed to initialize video writer")
|
||||
return
|
||||
|
||||
self.logger.info("Recording loop started")
|
||||
|
||||
while not self._stop_recording_event.is_set():
|
||||
try:
|
||||
# Capture frame
|
||||
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 200) # 200ms timeout
|
||||
|
||||
# Process frame
|
||||
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
|
||||
|
||||
# Convert to OpenCV format
|
||||
frame = self._convert_frame_to_opencv(FrameHead)
|
||||
|
||||
# Write frame to video
|
||||
if frame is not None and self.video_writer:
|
||||
self.video_writer.write(frame)
|
||||
self.frame_count += 1
|
||||
|
||||
# Release buffer
|
||||
mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData)
|
||||
|
||||
# Control frame rate (skip sleep if target_fps is 0 for maximum speed)
|
||||
if self.camera_config.target_fps > 0:
|
||||
time.sleep(1.0 / self.camera_config.target_fps)
|
||||
|
||||
except mvsdk.CameraException as e:
|
||||
if e.error_code == mvsdk.CAMERA_STATUS_TIME_OUT:
|
||||
continue # Timeout is normal, continue
|
||||
else:
|
||||
self.logger.error(f"Camera error during recording: {e.message}")
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in recording loop: {e}")
|
||||
break
|
||||
|
||||
self.logger.info("Recording loop ended")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Fatal error in recording loop: {e}")
|
||||
publish_recording_error(self.camera_config.name, str(e))
|
||||
finally:
|
||||
self._cleanup_recording()
|
||||
|
||||
def _initialize_video_writer(self) -> bool:
|
||||
"""Initialize OpenCV video writer"""
|
||||
try:
|
||||
# Get frame dimensions by capturing a test frame
|
||||
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 1000)
|
||||
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
|
||||
mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData)
|
||||
|
||||
# Set up video writer with configured codec
|
||||
fourcc = cv2.VideoWriter_fourcc(*self.camera_config.video_codec)
|
||||
frame_size = (FrameHead.iWidth, FrameHead.iHeight)
|
||||
|
||||
# Use 30 FPS for video writer if target_fps is 0 (unlimited)
|
||||
video_fps = self.camera_config.target_fps if self.camera_config.target_fps > 0 else 30.0
|
||||
|
||||
# Create video writer with quality settings
|
||||
self.video_writer = cv2.VideoWriter(self.output_filename, fourcc, video_fps, frame_size)
|
||||
|
||||
# Set quality if supported (for some codecs)
|
||||
if hasattr(self.video_writer, "set") and self.camera_config.video_quality:
|
||||
try:
|
||||
self.video_writer.set(cv2.VIDEOWRITER_PROP_QUALITY, self.camera_config.video_quality)
|
||||
except:
|
||||
pass # Quality setting not supported for this codec
|
||||
|
||||
if not self.video_writer.isOpened():
|
||||
self.logger.error(f"Failed to open video writer for {self.output_filename}")
|
||||
return False
|
||||
|
||||
self.logger.info(f"Video writer initialized - Size: {frame_size}, FPS: {self.camera_config.target_fps}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error initializing video writer: {e}")
|
||||
return False
|
||||
|
||||
def _convert_frame_to_opencv(self, frame_head) -> Optional[np.ndarray]:
|
||||
"""Convert camera frame to OpenCV format"""
|
||||
try:
|
||||
# Convert the frame buffer memory address to a proper buffer
|
||||
# that numpy can work with using mvsdk.c_ubyte
|
||||
frame_data_buffer = (mvsdk.c_ubyte * frame_head.uBytes).from_address(self.frame_buffer)
|
||||
|
||||
# Handle different bit depths
|
||||
if self.camera_config.bit_depth > 8:
|
||||
# For >8-bit, data is stored as 16-bit values
|
||||
frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint16)
|
||||
|
||||
if self.monoCamera:
|
||||
# Monochrome camera - convert to 8-bit BGR for video
|
||||
frame = frame_data.reshape((frame_head.iHeight, frame_head.iWidth))
|
||||
# Scale down to 8-bit (simple right shift)
|
||||
frame_8bit = (frame >> (self.camera_config.bit_depth - 8)).astype(np.uint8)
|
||||
frame_bgr = cv2.cvtColor(frame_8bit, cv2.COLOR_GRAY2BGR)
|
||||
else:
|
||||
# Color camera - convert to 8-bit BGR
|
||||
frame = frame_data.reshape((frame_head.iHeight, frame_head.iWidth, 3))
|
||||
# Scale down to 8-bit
|
||||
frame_bgr = (frame >> (self.camera_config.bit_depth - 8)).astype(np.uint8)
|
||||
else:
|
||||
# 8-bit data
|
||||
frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint8)
|
||||
|
||||
if self.monoCamera:
|
||||
# Monochrome camera - convert to BGR
|
||||
frame = frame_data.reshape((frame_head.iHeight, frame_head.iWidth))
|
||||
frame_bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
else:
|
||||
# Color camera - already in BGR format
|
||||
frame_bgr = frame_data.reshape((frame_head.iHeight, frame_head.iWidth, 3))
|
||||
|
||||
return frame_bgr
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error converting frame: {e}")
|
||||
return None
|
||||
|
||||
def _cleanup_recording(self) -> None:
|
||||
"""Clean up recording resources"""
|
||||
try:
|
||||
if self.video_writer:
|
||||
self.video_writer.release()
|
||||
self.video_writer = None
|
||||
|
||||
self.recording = False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during recording cleanup: {e}")
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""Test camera connection"""
|
||||
try:
|
||||
if self.hCamera is None:
|
||||
self.logger.error("Camera not initialized")
|
||||
return False
|
||||
|
||||
# Test connection using SDK function
|
||||
result = mvsdk.CameraConnectTest(self.hCamera)
|
||||
if result == 0: # CAMERA_STATUS_SUCCESS
|
||||
self.logger.info("Camera connection test passed")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Camera connection test failed with code: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error testing camera connection: {e}")
|
||||
return False
|
||||
|
||||
def reconnect(self) -> bool:
|
||||
"""Attempt to reconnect to the camera"""
|
||||
try:
|
||||
if self.hCamera is None:
|
||||
self.logger.error("Camera not initialized, cannot reconnect")
|
||||
return False
|
||||
|
||||
self.logger.info("Attempting to reconnect camera...")
|
||||
|
||||
# Stop any ongoing operations
|
||||
if self.recording:
|
||||
self.logger.info("Stopping recording before reconnect")
|
||||
self.stop_recording()
|
||||
|
||||
# Attempt reconnection using SDK function
|
||||
result = mvsdk.CameraReConnect(self.hCamera)
|
||||
if result == 0: # CAMERA_STATUS_SUCCESS
|
||||
self.logger.info("Camera reconnected successfully")
|
||||
|
||||
# Restart camera if it was playing
|
||||
try:
|
||||
mvsdk.CameraPlay(self.hCamera)
|
||||
self.logger.info("Camera restarted after reconnection")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to restart camera after reconnection: {e}")
|
||||
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Camera reconnection failed with code: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during camera reconnection: {e}")
|
||||
return False
|
||||
|
||||
def restart_grab(self) -> bool:
|
||||
"""Restart the camera grab process"""
|
||||
try:
|
||||
if self.hCamera is None:
|
||||
self.logger.error("Camera not initialized")
|
||||
return False
|
||||
|
||||
self.logger.info("Restarting camera grab process...")
|
||||
|
||||
# Stop any ongoing recording
|
||||
if self.recording:
|
||||
self.logger.info("Stopping recording before restart")
|
||||
self.stop_recording()
|
||||
|
||||
# Restart grab using SDK function
|
||||
result = mvsdk.CameraRestartGrab(self.hCamera)
|
||||
if result == 0: # CAMERA_STATUS_SUCCESS
|
||||
self.logger.info("Camera grab restarted successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Camera grab restart failed with code: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error restarting camera grab: {e}")
|
||||
return False
|
||||
|
||||
def reset_timestamp(self) -> bool:
|
||||
"""Reset camera timestamp"""
|
||||
try:
|
||||
if self.hCamera is None:
|
||||
self.logger.error("Camera not initialized")
|
||||
return False
|
||||
|
||||
self.logger.info("Resetting camera timestamp...")
|
||||
|
||||
result = mvsdk.CameraRstTimeStamp(self.hCamera)
|
||||
if result == 0: # CAMERA_STATUS_SUCCESS
|
||||
self.logger.info("Camera timestamp reset successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Camera timestamp reset failed with code: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error resetting camera timestamp: {e}")
|
||||
return False
|
||||
|
||||
def full_reset(self) -> bool:
|
||||
"""Perform a full camera reset (uninitialize and reinitialize)"""
|
||||
try:
|
||||
self.logger.info("Performing full camera reset...")
|
||||
|
||||
# Stop any ongoing recording
|
||||
if self.recording:
|
||||
self.logger.info("Stopping recording before reset")
|
||||
self.stop_recording()
|
||||
|
||||
# Store device info for reinitialization
|
||||
device_info = self.device_info
|
||||
|
||||
# Cleanup current camera
|
||||
self._cleanup_camera()
|
||||
|
||||
# Wait a moment
|
||||
time.sleep(1)
|
||||
|
||||
# Reinitialize camera
|
||||
self.device_info = device_info
|
||||
success = self._initialize_camera()
|
||||
|
||||
if success:
|
||||
self.logger.info("Full camera reset completed successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.error("Full camera reset failed during reinitialization")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during full camera reset: {e}")
|
||||
return False
|
||||
|
||||
def _cleanup_camera(self) -> None:
|
||||
"""Clean up camera resources"""
|
||||
try:
|
||||
# Stop camera if running
|
||||
if self.hCamera is not None:
|
||||
try:
|
||||
mvsdk.CameraStop(self.hCamera)
|
||||
except:
|
||||
pass # Ignore errors during stop
|
||||
|
||||
# Uninitialize camera
|
||||
try:
|
||||
mvsdk.CameraUnInit(self.hCamera)
|
||||
except:
|
||||
pass # Ignore errors during uninit
|
||||
|
||||
self.hCamera = None
|
||||
|
||||
# Free frame buffer
|
||||
if self.frame_buffer is not None:
|
||||
try:
|
||||
mvsdk.CameraAlignFree(self.frame_buffer)
|
||||
except:
|
||||
pass # Ignore errors during free
|
||||
|
||||
self.frame_buffer = None
|
||||
|
||||
self.logger.info("Camera resources cleaned up")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during camera cleanup: {e}")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up camera resources"""
|
||||
try:
|
||||
# Stop recording if active
|
||||
if self.recording:
|
||||
self.stop_recording()
|
||||
|
||||
# Clean up camera
|
||||
if self.hCamera:
|
||||
mvsdk.CameraUnInit(self.hCamera)
|
||||
self.hCamera = None
|
||||
|
||||
# Free frame buffer
|
||||
if self.frame_buffer:
|
||||
mvsdk.CameraAlignFree(self.frame_buffer)
|
||||
self.frame_buffer = None
|
||||
|
||||
self.logger.info("Camera resources cleaned up")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during cleanup: {e}")
|
||||
|
||||
def is_recording(self) -> bool:
|
||||
"""Check if currently recording"""
|
||||
return self.recording
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get recorder status"""
|
||||
return {"camera_name": self.camera_config.name, "is_recording": self.recording, "current_file": self.output_filename, "frame_count": self.frame_count, "start_time": self.start_time.isoformat() if self.start_time else None, "camera_initialized": self.hCamera is not None, "storage_path": self.camera_config.storage_path}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
SDK Configuration for the USDA Vision Camera System.
|
||||
|
||||
This module handles SDK initialization and configuration to suppress error messages.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Add camera SDK to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "camera_sdk"))
|
||||
import mvsdk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global flag to track SDK initialization
|
||||
_sdk_initialized = False
|
||||
|
||||
|
||||
def initialize_sdk_with_suppression():
|
||||
"""Initialize the camera SDK with error suppression"""
|
||||
global _sdk_initialized
|
||||
|
||||
if _sdk_initialized:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Initialize SDK with English language
|
||||
result = mvsdk.CameraSdkInit(1)
|
||||
if result == 0:
|
||||
logger.info("Camera SDK initialized successfully")
|
||||
|
||||
# Try to set system options to suppress logging
|
||||
try:
|
||||
# These are common options that might control logging
|
||||
# We'll try them and ignore failures since they might not be supported
|
||||
|
||||
# Try to disable debug output
|
||||
try:
|
||||
mvsdk.CameraSetSysOption("DebugLevel", "0")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Try to disable console output
|
||||
try:
|
||||
mvsdk.CameraSetSysOption("ConsoleOutput", "0")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Try to disable error logging
|
||||
try:
|
||||
mvsdk.CameraSetSysOption("ErrorLog", "0")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Try to set log level to none
|
||||
try:
|
||||
mvsdk.CameraSetSysOption("LogLevel", "0")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Try to disable verbose mode
|
||||
try:
|
||||
mvsdk.CameraSetSysOption("Verbose", "0")
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.debug("Attempted to configure SDK logging options")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not configure SDK logging options: {e}")
|
||||
|
||||
_sdk_initialized = True
|
||||
return True
|
||||
else:
|
||||
logger.error(f"SDK initialization failed with code: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SDK initialization failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def ensure_sdk_initialized():
|
||||
"""Ensure the SDK is initialized before camera operations"""
|
||||
if not _sdk_initialized:
|
||||
return initialize_sdk_with_suppression()
|
||||
return True
|
||||
412
camera-management-api/usda_vision_system/camera/streamer.py
Normal file
412
camera-management-api/usda_vision_system/camera/streamer.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
Camera Streamer for the USDA Vision Camera System.
|
||||
|
||||
This module provides live preview streaming from GigE cameras without blocking recording.
|
||||
It creates a separate camera connection for streaming that doesn't interfere with recording.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import cv2
|
||||
import numpy as np
|
||||
import contextlib
|
||||
from typing import Optional, Dict, Any, Generator
|
||||
from datetime import datetime
|
||||
import queue
|
||||
|
||||
# Add camera SDK to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "camera_sdk"))
|
||||
import mvsdk
|
||||
|
||||
from ..core.config import CameraConfig
|
||||
from ..core.state_manager import StateManager
|
||||
from ..core.events import EventSystem
|
||||
from .sdk_config import ensure_sdk_initialized
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def suppress_camera_errors():
|
||||
"""Context manager to temporarily suppress camera SDK error output"""
|
||||
# Save original file descriptors
|
||||
original_stderr = os.dup(2)
|
||||
original_stdout = os.dup(1)
|
||||
|
||||
try:
|
||||
# Redirect stderr and stdout to devnull
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull, 2) # stderr
|
||||
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
|
||||
os.close(devnull)
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
# Restore original file descriptors
|
||||
os.dup2(original_stderr, 2)
|
||||
os.dup2(original_stdout, 1)
|
||||
os.close(original_stderr)
|
||||
os.close(original_stdout)
|
||||
|
||||
|
||||
class CameraStreamer:
|
||||
"""Provides live preview streaming from cameras without blocking recording"""
|
||||
|
||||
def __init__(self, camera_config: CameraConfig, device_info: Any, state_manager: StateManager, event_system: EventSystem):
|
||||
self.camera_config = camera_config
|
||||
self.device_info = device_info
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.logger = logging.getLogger(f"{__name__}.{camera_config.name}")
|
||||
|
||||
# Camera handle and properties (separate from recorder)
|
||||
self.hCamera: Optional[int] = None
|
||||
self.cap = None
|
||||
self.monoCamera = False
|
||||
self.frame_buffer = None
|
||||
self.frame_buffer_size = 0
|
||||
|
||||
# Streaming state
|
||||
self.streaming = False
|
||||
self._streaming_thread: Optional[threading.Thread] = None
|
||||
self._stop_streaming_event = threading.Event()
|
||||
self._frame_queue = queue.Queue(maxsize=5) # Buffer for latest frames
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Stream settings (optimized for preview)
|
||||
self.preview_fps = 10.0 # Lower FPS for preview to reduce load
|
||||
self.preview_quality = 70 # JPEG quality for streaming
|
||||
|
||||
def start_streaming(self) -> bool:
|
||||
"""Start streaming preview frames"""
|
||||
with self._lock:
|
||||
if self.streaming:
|
||||
self.logger.warning("Streaming already active")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Initialize camera for streaming
|
||||
if not self._initialize_camera():
|
||||
return False
|
||||
|
||||
# Start streaming thread
|
||||
self._stop_streaming_event.clear()
|
||||
self._streaming_thread = threading.Thread(target=self._streaming_loop, daemon=True)
|
||||
self._streaming_thread.start()
|
||||
|
||||
self.streaming = True
|
||||
self.logger.info(f"Started streaming for camera: {self.camera_config.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error starting streaming: {e}")
|
||||
self._cleanup_camera()
|
||||
return False
|
||||
|
||||
def stop_streaming(self) -> bool:
|
||||
"""Stop streaming preview frames"""
|
||||
with self._lock:
|
||||
if not self.streaming:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Signal streaming thread to stop
|
||||
self._stop_streaming_event.set()
|
||||
|
||||
# Wait for thread to finish
|
||||
if self._streaming_thread and self._streaming_thread.is_alive():
|
||||
self._streaming_thread.join(timeout=5.0)
|
||||
|
||||
# Cleanup camera resources
|
||||
self._cleanup_camera()
|
||||
|
||||
self.streaming = False
|
||||
self.logger.info(f"Stopped streaming for camera: {self.camera_config.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping streaming: {e}")
|
||||
return False
|
||||
|
||||
def get_latest_frame(self) -> Optional[bytes]:
|
||||
"""Get the latest frame as JPEG bytes for streaming"""
|
||||
try:
|
||||
# Get latest frame from queue (non-blocking)
|
||||
frame = self._frame_queue.get_nowait()
|
||||
|
||||
# Encode as JPEG
|
||||
_, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.preview_quality])
|
||||
return buffer.tobytes()
|
||||
|
||||
except queue.Empty:
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting latest frame: {e}")
|
||||
return None
|
||||
|
||||
def get_frame_generator(self) -> Generator[bytes, None, None]:
|
||||
"""Generator for MJPEG streaming"""
|
||||
while self.streaming:
|
||||
frame_bytes = self.get_latest_frame()
|
||||
if frame_bytes:
|
||||
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame_bytes + b"\r\n")
|
||||
else:
|
||||
time.sleep(0.1) # Wait a bit if no frame available
|
||||
|
||||
def _initialize_camera(self) -> bool:
|
||||
"""Initialize camera for streaming (separate from recording)"""
|
||||
try:
|
||||
self.logger.info(f"Initializing camera for streaming: {self.camera_config.name}")
|
||||
|
||||
# Ensure SDK is initialized
|
||||
ensure_sdk_initialized()
|
||||
|
||||
# Check if device_info is valid
|
||||
if self.device_info is None:
|
||||
self.logger.error("No device info provided for camera initialization")
|
||||
return False
|
||||
|
||||
# Initialize camera (suppress output to avoid MVCAMAPI error messages)
|
||||
with suppress_camera_errors():
|
||||
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
|
||||
self.logger.info("Camera initialized successfully for streaming")
|
||||
|
||||
# Get camera capabilities
|
||||
self.cap = mvsdk.CameraGetCapability(self.hCamera)
|
||||
|
||||
# Determine if camera is monochrome
|
||||
self.monoCamera = self.cap.sIspCapacity.bMonoSensor != 0
|
||||
|
||||
# Set output format based on camera type and bit depth
|
||||
if self.monoCamera:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8)
|
||||
else:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8)
|
||||
|
||||
# Configure camera settings for streaming (optimized for preview)
|
||||
self._configure_streaming_settings()
|
||||
|
||||
# Allocate frame buffer
|
||||
bytes_per_pixel = 1 if self.monoCamera else 3
|
||||
self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
|
||||
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
|
||||
|
||||
# Start camera
|
||||
mvsdk.CameraPlay(self.hCamera)
|
||||
self.logger.info("Camera started successfully for streaming")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error initializing camera for streaming: {e}")
|
||||
self._cleanup_camera()
|
||||
return False
|
||||
|
||||
def _configure_streaming_settings(self):
|
||||
"""Configure camera settings from config.json for streaming"""
|
||||
try:
|
||||
# Set trigger mode to free run for continuous streaming
|
||||
mvsdk.CameraSetTriggerMode(self.hCamera, 0)
|
||||
|
||||
# Set manual exposure
|
||||
mvsdk.CameraSetAeState(self.hCamera, 0) # Disable auto exposure
|
||||
exposure_us = int(self.camera_config.exposure_ms * 1000) # Convert ms to microseconds
|
||||
mvsdk.CameraSetExposureTime(self.hCamera, exposure_us)
|
||||
|
||||
# Set analog gain
|
||||
gain_value = int(self.camera_config.gain * 100) # Convert to camera units
|
||||
mvsdk.CameraSetAnalogGain(self.hCamera, gain_value)
|
||||
|
||||
# Set frame rate for streaming (lower than recording)
|
||||
if hasattr(mvsdk, "CameraSetFrameSpeed"):
|
||||
mvsdk.CameraSetFrameSpeed(self.hCamera, int(self.preview_fps))
|
||||
|
||||
# Configure image quality settings
|
||||
self._configure_image_quality()
|
||||
|
||||
# Configure noise reduction
|
||||
self._configure_noise_reduction()
|
||||
|
||||
# Configure color settings (for color cameras)
|
||||
if not self.monoCamera:
|
||||
self._configure_color_settings()
|
||||
|
||||
# Configure advanced settings
|
||||
self._configure_advanced_settings()
|
||||
|
||||
self.logger.info(f"Streaming settings configured: exposure={self.camera_config.exposure_ms}ms, gain={self.camera_config.gain}, fps={self.preview_fps}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not configure some streaming settings: {e}")
|
||||
|
||||
def _streaming_loop(self):
|
||||
"""Main streaming loop that captures frames continuously"""
|
||||
self.logger.info("Starting streaming loop")
|
||||
|
||||
try:
|
||||
while not self._stop_streaming_event.is_set():
|
||||
try:
|
||||
# Capture frame with timeout
|
||||
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 200) # 200ms timeout
|
||||
|
||||
# Process frame
|
||||
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
|
||||
|
||||
# Convert to OpenCV format
|
||||
frame = self._convert_frame_to_opencv(FrameHead)
|
||||
|
||||
if frame is not None:
|
||||
# Add frame to queue (replace oldest if queue is full)
|
||||
try:
|
||||
self._frame_queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
# Remove oldest frame and add new one
|
||||
try:
|
||||
self._frame_queue.get_nowait()
|
||||
self._frame_queue.put_nowait(frame)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Release buffer
|
||||
mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData)
|
||||
|
||||
# Control frame rate
|
||||
time.sleep(1.0 / self.preview_fps)
|
||||
|
||||
except Exception as e:
|
||||
if not self._stop_streaming_event.is_set():
|
||||
self.logger.error(f"Error in streaming loop: {e}")
|
||||
time.sleep(0.1) # Brief pause before retrying
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Fatal error in streaming loop: {e}")
|
||||
finally:
|
||||
self.logger.info("Streaming loop ended")
|
||||
|
||||
def _convert_frame_to_opencv(self, FrameHead) -> Optional[np.ndarray]:
|
||||
"""Convert camera frame to OpenCV format"""
|
||||
try:
|
||||
# Convert the frame buffer memory address to a proper buffer
|
||||
# that numpy can work with using mvsdk.c_ubyte
|
||||
frame_data_buffer = (mvsdk.c_ubyte * FrameHead.uBytes).from_address(self.frame_buffer)
|
||||
|
||||
if self.monoCamera:
|
||||
# Monochrome camera
|
||||
frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint8)
|
||||
frame = frame_data.reshape((FrameHead.iHeight, FrameHead.iWidth))
|
||||
# Convert to 3-channel for consistency
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
else:
|
||||
# Color camera (BGR format)
|
||||
frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint8)
|
||||
frame = frame_data.reshape((FrameHead.iHeight, FrameHead.iWidth, 3))
|
||||
|
||||
return frame
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error converting frame: {e}")
|
||||
return None
|
||||
|
||||
def _cleanup_camera(self):
|
||||
"""Clean up camera resources"""
|
||||
try:
|
||||
if self.frame_buffer:
|
||||
mvsdk.CameraAlignFree(self.frame_buffer)
|
||||
self.frame_buffer = None
|
||||
|
||||
if self.hCamera is not None:
|
||||
mvsdk.CameraUnInit(self.hCamera)
|
||||
self.hCamera = None
|
||||
|
||||
self.logger.info("Camera resources cleaned up for streaming")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cleaning up camera resources: {e}")
|
||||
|
||||
def is_streaming(self) -> bool:
|
||||
"""Check if streaming is active"""
|
||||
return self.streaming
|
||||
|
||||
def _configure_image_quality(self) -> None:
|
||||
"""Configure image quality settings"""
|
||||
try:
|
||||
# Set sharpness (0-200, default 100)
|
||||
mvsdk.CameraSetSharpness(self.hCamera, self.camera_config.sharpness)
|
||||
|
||||
# Set contrast (0-200, default 100)
|
||||
mvsdk.CameraSetContrast(self.hCamera, self.camera_config.contrast)
|
||||
|
||||
# Set gamma (0-300, default 100)
|
||||
mvsdk.CameraSetGamma(self.hCamera, self.camera_config.gamma)
|
||||
|
||||
# Set saturation for color cameras (0-200, default 100)
|
||||
if not self.monoCamera:
|
||||
mvsdk.CameraSetSaturation(self.hCamera, self.camera_config.saturation)
|
||||
|
||||
self.logger.info(f"Image quality configured - Sharpness: {self.camera_config.sharpness}, " f"Contrast: {self.camera_config.contrast}, Gamma: {self.camera_config.gamma}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring image quality: {e}")
|
||||
|
||||
def _configure_noise_reduction(self) -> None:
|
||||
"""Configure noise reduction settings"""
|
||||
try:
|
||||
# Note: Some noise reduction settings may require specific SDK functions
|
||||
# that might not be available in all SDK versions
|
||||
self.logger.info(f"Noise reduction configured - Filter: {self.camera_config.noise_filter_enabled}, " f"3D Denoise: {self.camera_config.denoise_3d_enabled}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring noise reduction: {e}")
|
||||
|
||||
def _configure_color_settings(self) -> None:
|
||||
"""Configure color settings for color cameras"""
|
||||
try:
|
||||
# Set white balance mode
|
||||
mvsdk.CameraSetWbMode(self.hCamera, self.camera_config.auto_white_balance)
|
||||
|
||||
# Set color temperature preset if not using auto white balance
|
||||
if not self.camera_config.auto_white_balance:
|
||||
mvsdk.CameraSetPresetClrTemp(self.hCamera, self.camera_config.color_temperature_preset)
|
||||
|
||||
# Set manual RGB gains for manual white balance
|
||||
red_gain = int(self.camera_config.wb_red_gain * 100) # Convert to camera units
|
||||
green_gain = int(self.camera_config.wb_green_gain * 100)
|
||||
blue_gain = int(self.camera_config.wb_blue_gain * 100)
|
||||
mvsdk.CameraSetUserClrTempGain(self.hCamera, red_gain, green_gain, blue_gain)
|
||||
|
||||
self.logger.info(f"Color settings configured - Auto WB: {self.camera_config.auto_white_balance}, " f"Color Temp Preset: {self.camera_config.color_temperature_preset}, " f"RGB Gains: R={self.camera_config.wb_red_gain}, G={self.camera_config.wb_green_gain}, B={self.camera_config.wb_blue_gain}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring color settings: {e}")
|
||||
|
||||
def _configure_advanced_settings(self) -> None:
|
||||
"""Configure advanced camera settings"""
|
||||
try:
|
||||
# Set anti-flicker
|
||||
mvsdk.CameraSetAntiFlick(self.hCamera, self.camera_config.anti_flicker_enabled)
|
||||
|
||||
# Set light frequency (0=50Hz, 1=60Hz)
|
||||
mvsdk.CameraSetLightFrequency(self.hCamera, self.camera_config.light_frequency)
|
||||
|
||||
# Configure HDR if enabled (check if HDR functions are available)
|
||||
try:
|
||||
if self.camera_config.hdr_enabled:
|
||||
mvsdk.CameraSetHDR(self.hCamera, 1) # Enable HDR
|
||||
mvsdk.CameraSetHDRGainMode(self.hCamera, self.camera_config.hdr_gain_mode)
|
||||
self.logger.info(f"HDR enabled with gain mode: {self.camera_config.hdr_gain_mode}")
|
||||
else:
|
||||
mvsdk.CameraSetHDR(self.hCamera, 0) # Disable HDR
|
||||
except AttributeError:
|
||||
self.logger.info("HDR functions not available in this SDK version, skipping HDR configuration")
|
||||
|
||||
self.logger.info(f"Advanced settings configured - Anti-flicker: {self.camera_config.anti_flicker_enabled}, " f"Light Freq: {self.camera_config.light_frequency}Hz, HDR: {self.camera_config.hdr_enabled}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error configuring advanced settings: {e}")
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor to ensure cleanup"""
|
||||
if self.streaming:
|
||||
self.stop_streaming()
|
||||
15
camera-management-api/usda_vision_system/core/__init__.py
Normal file
15
camera-management-api/usda_vision_system/core/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
USDA Vision Camera System - Core Module
|
||||
|
||||
This module contains the core functionality for the USDA vision camera system,
|
||||
including configuration management, state management, and event handling.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "USDA Vision Team"
|
||||
|
||||
from .config import Config
|
||||
from .state_manager import StateManager
|
||||
from .events import EventSystem
|
||||
|
||||
__all__ = ["Config", "StateManager", "EventSystem"]
|
||||
233
camera-management-api/usda_vision_system/core/config.py
Normal file
233
camera-management-api/usda_vision_system/core/config.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Configuration management for the USDA Vision Camera System.
|
||||
|
||||
This module handles all configuration settings including MQTT broker settings,
|
||||
camera configurations, storage paths, and system parameters.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class MQTTConfig:
|
||||
"""MQTT broker configuration"""
|
||||
|
||||
broker_host: str = "192.168.1.110"
|
||||
broker_port: int = 1883
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
topics: Optional[Dict[str, str]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.topics is None:
|
||||
self.topics = {"vibratory_conveyor": "vision/vibratory_conveyor/state", "blower_separator": "vision/blower_separator/state"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraConfig:
|
||||
"""Individual camera configuration"""
|
||||
|
||||
name: str
|
||||
machine_topic: str # Which MQTT topic triggers this camera
|
||||
storage_path: str
|
||||
exposure_ms: float = 1.0
|
||||
gain: float = 3.5
|
||||
target_fps: float = 3.0
|
||||
enabled: bool = True
|
||||
|
||||
# Video recording settings
|
||||
video_format: str = "mp4" # Video file format (mp4, avi)
|
||||
video_codec: str = "mp4v" # Video codec (mp4v for MP4, XVID for AVI)
|
||||
video_quality: int = 95 # Video quality (0-100, higher is better)
|
||||
|
||||
# Auto-recording settings
|
||||
auto_start_recording_enabled: bool = False # Enable automatic recording when machine turns on
|
||||
auto_recording_max_retries: int = 3 # Maximum retry attempts for failed auto-recording starts
|
||||
auto_recording_retry_delay_seconds: int = 5 # Delay between retry attempts
|
||||
|
||||
# Image Quality Settings
|
||||
sharpness: int = 100 # 0-200, default 100 (no sharpening)
|
||||
contrast: int = 100 # 0-200, default 100 (normal contrast)
|
||||
saturation: int = 100 # 0-200, default 100 (normal saturation, color cameras only)
|
||||
gamma: int = 100 # 0-300, default 100 (normal gamma)
|
||||
|
||||
# Noise Reduction
|
||||
noise_filter_enabled: bool = True # Enable basic noise filtering
|
||||
denoise_3d_enabled: bool = False # Enable advanced 3D denoising (may reduce FPS)
|
||||
|
||||
# Color Settings (for color cameras)
|
||||
auto_white_balance: bool = True # Enable automatic white balance
|
||||
color_temperature_preset: int = 0 # 0=auto, 1=daylight, 2=fluorescent, etc.
|
||||
|
||||
# Manual White Balance RGB Gains (for manual white balance mode)
|
||||
wb_red_gain: float = 1.0 # Red channel gain (0.0-3.99, default 1.0)
|
||||
wb_green_gain: float = 1.0 # Green channel gain (0.0-3.99, default 1.0)
|
||||
wb_blue_gain: float = 1.0 # Blue channel gain (0.0-3.99, default 1.0)
|
||||
|
||||
# Advanced Settings
|
||||
anti_flicker_enabled: bool = True # Reduce artificial lighting flicker
|
||||
light_frequency: int = 1 # 0=50Hz, 1=60Hz (match local power frequency)
|
||||
|
||||
# Bit Depth & Format
|
||||
bit_depth: int = 8 # 8, 10, 12, or 16 bits per channel
|
||||
|
||||
# HDR Settings
|
||||
hdr_enabled: bool = False # Enable High Dynamic Range
|
||||
hdr_gain_mode: int = 0 # HDR processing mode
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageConfig:
|
||||
"""Storage configuration"""
|
||||
|
||||
base_path: str = "/storage"
|
||||
max_file_size_mb: int = 1000 # Max size per video file
|
||||
max_recording_duration_minutes: int = 60 # Max recording duration
|
||||
cleanup_older_than_days: int = 30 # Auto cleanup old files
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemConfig:
|
||||
"""System-wide configuration"""
|
||||
|
||||
camera_check_interval_seconds: int = 2
|
||||
log_level: str = "INFO"
|
||||
log_file: str = "usda_vision_system.log"
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
enable_api: bool = True
|
||||
timezone: str = "America/New_York"
|
||||
|
||||
# Auto-recording system settings
|
||||
auto_recording_enabled: bool = True # Global enable/disable for auto-recording feature # Atlanta, Georgia timezone
|
||||
|
||||
|
||||
class Config:
|
||||
"""Main configuration manager"""
|
||||
|
||||
def __init__(self, config_file: Optional[str] = None):
|
||||
self.config_file = config_file or "config.json"
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Default configurations
|
||||
self.mqtt = MQTTConfig()
|
||||
self.storage = StorageConfig()
|
||||
self.system = SystemConfig()
|
||||
|
||||
# Camera configurations - will be populated from config file or defaults
|
||||
self.cameras: List[CameraConfig] = []
|
||||
|
||||
# Load configuration
|
||||
self.load_config()
|
||||
|
||||
# Ensure storage directories exist
|
||||
self._ensure_storage_directories()
|
||||
|
||||
def load_config(self) -> None:
|
||||
"""Load configuration from file"""
|
||||
config_path = Path(self.config_file)
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
config_data = json.load(f)
|
||||
|
||||
# Load MQTT config
|
||||
if "mqtt" in config_data:
|
||||
mqtt_data = config_data["mqtt"]
|
||||
self.mqtt = MQTTConfig(**mqtt_data)
|
||||
|
||||
# Load storage config
|
||||
if "storage" in config_data:
|
||||
storage_data = config_data["storage"]
|
||||
self.storage = StorageConfig(**storage_data)
|
||||
|
||||
# Load system config
|
||||
if "system" in config_data:
|
||||
system_data = config_data["system"]
|
||||
self.system = SystemConfig(**system_data)
|
||||
|
||||
# Load camera configs
|
||||
if "cameras" in config_data:
|
||||
self.cameras = []
|
||||
for cam_data in config_data["cameras"]:
|
||||
# Set defaults for new video format fields if not present
|
||||
cam_data.setdefault("video_format", "mp4")
|
||||
cam_data.setdefault("video_codec", "mp4v")
|
||||
cam_data.setdefault("video_quality", 95)
|
||||
self.cameras.append(CameraConfig(**cam_data))
|
||||
else:
|
||||
self._create_default_camera_configs()
|
||||
|
||||
self.logger.info(f"Configuration loaded from {config_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading config from {config_path}: {e}")
|
||||
self._create_default_camera_configs()
|
||||
else:
|
||||
self.logger.info(f"Config file {config_path} not found, using defaults")
|
||||
self._create_default_camera_configs()
|
||||
self.save_config() # Save default config
|
||||
|
||||
def _create_default_camera_configs(self) -> None:
|
||||
"""Create default camera configurations"""
|
||||
self.cameras = [CameraConfig(name="camera1", machine_topic="vibratory_conveyor", storage_path=os.path.join(self.storage.base_path, "camera1")), CameraConfig(name="camera2", machine_topic="blower_separator", storage_path=os.path.join(self.storage.base_path, "camera2"))]
|
||||
|
||||
def save_config(self) -> None:
|
||||
"""Save current configuration to file"""
|
||||
config_data = {"mqtt": asdict(self.mqtt), "storage": asdict(self.storage), "system": asdict(self.system), "cameras": [asdict(cam) for cam in self.cameras]}
|
||||
|
||||
try:
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
self.logger.info(f"Configuration saved to {self.config_file}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving config to {self.config_file}: {e}")
|
||||
|
||||
def _ensure_storage_directories(self) -> None:
|
||||
"""Ensure all storage directories exist"""
|
||||
try:
|
||||
# Create base storage directory
|
||||
Path(self.storage.base_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create camera-specific directories
|
||||
for camera in self.cameras:
|
||||
Path(camera.storage_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.logger.info("Storage directories verified/created")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating storage directories: {e}")
|
||||
|
||||
def get_camera_by_topic(self, topic: str) -> Optional[CameraConfig]:
|
||||
"""Get camera configuration by MQTT topic"""
|
||||
for camera in self.cameras:
|
||||
if camera.machine_topic == topic:
|
||||
return camera
|
||||
return None
|
||||
|
||||
def get_camera_by_name(self, name: str) -> Optional[CameraConfig]:
|
||||
"""Get camera configuration by name"""
|
||||
for camera in self.cameras:
|
||||
if camera.name == name:
|
||||
return camera
|
||||
return None
|
||||
|
||||
def update_camera_config(self, name: str, **kwargs) -> bool:
|
||||
"""Update camera configuration"""
|
||||
camera = self.get_camera_by_name(name)
|
||||
if camera:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(camera, key):
|
||||
setattr(camera, key, value)
|
||||
self.save_config()
|
||||
return True
|
||||
return False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert configuration to dictionary"""
|
||||
return {"mqtt": asdict(self.mqtt), "storage": asdict(self.storage), "system": asdict(self.system), "cameras": [asdict(cam) for cam in self.cameras]}
|
||||
195
camera-management-api/usda_vision_system/core/events.py
Normal file
195
camera-management-api/usda_vision_system/core/events.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Event system for the USDA Vision Camera System.
|
||||
|
||||
This module provides a thread-safe event system for communication between
|
||||
different components of the system (MQTT, cameras, recording, etc.).
|
||||
"""
|
||||
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, List, Callable, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""Event types for the system"""
|
||||
MACHINE_STATE_CHANGED = "machine_state_changed"
|
||||
CAMERA_STATUS_CHANGED = "camera_status_changed"
|
||||
RECORDING_STARTED = "recording_started"
|
||||
RECORDING_STOPPED = "recording_stopped"
|
||||
RECORDING_ERROR = "recording_error"
|
||||
MQTT_CONNECTED = "mqtt_connected"
|
||||
MQTT_DISCONNECTED = "mqtt_disconnected"
|
||||
SYSTEM_SHUTDOWN = "system_shutdown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""Event data structure"""
|
||||
event_type: EventType
|
||||
source: str
|
||||
data: Dict[str, Any]
|
||||
timestamp: datetime
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.timestamp, datetime):
|
||||
self.timestamp = datetime.now()
|
||||
|
||||
|
||||
class EventSystem:
|
||||
"""Thread-safe event system for inter-component communication"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._subscribers: Dict[EventType, List[Callable]] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._event_history: List[Event] = []
|
||||
self._max_history = 1000 # Keep last 1000 events
|
||||
|
||||
def subscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None:
|
||||
"""Subscribe to an event type"""
|
||||
with self._lock:
|
||||
if event_type not in self._subscribers:
|
||||
self._subscribers[event_type] = []
|
||||
|
||||
if callback not in self._subscribers[event_type]:
|
||||
self._subscribers[event_type].append(callback)
|
||||
self.logger.debug(f"Subscribed to {event_type.value}")
|
||||
|
||||
def unsubscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None:
|
||||
"""Unsubscribe from an event type"""
|
||||
with self._lock:
|
||||
if event_type in self._subscribers:
|
||||
try:
|
||||
self._subscribers[event_type].remove(callback)
|
||||
self.logger.debug(f"Unsubscribed from {event_type.value}")
|
||||
except ValueError:
|
||||
pass # Callback wasn't subscribed
|
||||
|
||||
def publish(self, event_type: EventType, source: str, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Publish an event"""
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
event = Event(
|
||||
event_type=event_type,
|
||||
source=source,
|
||||
data=data,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
# Add to history
|
||||
with self._lock:
|
||||
self._event_history.append(event)
|
||||
if len(self._event_history) > self._max_history:
|
||||
self._event_history.pop(0)
|
||||
|
||||
# Notify subscribers
|
||||
self._notify_subscribers(event)
|
||||
|
||||
def _notify_subscribers(self, event: Event) -> None:
|
||||
"""Notify all subscribers of an event"""
|
||||
with self._lock:
|
||||
subscribers = self._subscribers.get(event.event_type, []).copy()
|
||||
|
||||
for callback in subscribers:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in event callback for {event.event_type.value}: {e}")
|
||||
|
||||
def get_recent_events(self, event_type: Optional[EventType] = None, limit: int = 100) -> List[Event]:
|
||||
"""Get recent events, optionally filtered by type"""
|
||||
with self._lock:
|
||||
events = self._event_history.copy()
|
||||
|
||||
if event_type:
|
||||
events = [e for e in events if e.event_type == event_type]
|
||||
|
||||
return events[-limit:] if limit else events
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear event history"""
|
||||
with self._lock:
|
||||
self._event_history.clear()
|
||||
self.logger.info("Event history cleared")
|
||||
|
||||
def get_subscriber_count(self, event_type: EventType) -> int:
|
||||
"""Get number of subscribers for an event type"""
|
||||
with self._lock:
|
||||
return len(self._subscribers.get(event_type, []))
|
||||
|
||||
def get_all_event_types(self) -> List[EventType]:
|
||||
"""Get all event types that have subscribers"""
|
||||
with self._lock:
|
||||
return list(self._subscribers.keys())
|
||||
|
||||
|
||||
# Global event system instance
|
||||
event_system = EventSystem()
|
||||
|
||||
|
||||
# Convenience functions for common events
|
||||
def publish_machine_state_changed(machine_name: str, state: str, source: str = "mqtt") -> None:
|
||||
"""Publish machine state change event"""
|
||||
event_system.publish(
|
||||
EventType.MACHINE_STATE_CHANGED,
|
||||
source,
|
||||
{
|
||||
"machine_name": machine_name,
|
||||
"state": state,
|
||||
"previous_state": None # Could be enhanced to track previous state
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_camera_status_changed(camera_name: str, status: str, details: str = "", source: str = "camera_monitor") -> None:
|
||||
"""Publish camera status change event"""
|
||||
event_system.publish(
|
||||
EventType.CAMERA_STATUS_CHANGED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"status": status,
|
||||
"details": details
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_started(camera_name: str, filename: str, source: str = "recorder") -> None:
|
||||
"""Publish recording started event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_STARTED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"filename": filename
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_stopped(camera_name: str, filename: str, duration_seconds: float, source: str = "recorder") -> None:
|
||||
"""Publish recording stopped event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_STOPPED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"filename": filename,
|
||||
"duration_seconds": duration_seconds
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_error(camera_name: str, error_message: str, source: str = "recorder") -> None:
|
||||
"""Publish recording error event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_ERROR,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"error_message": error_message
|
||||
}
|
||||
)
|
||||
260
camera-management-api/usda_vision_system/core/logging_config.py
Normal file
260
camera-management-api/usda_vision_system/core/logging_config.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Logging configuration for the USDA Vision Camera System.
|
||||
|
||||
This module provides comprehensive logging setup with rotation, formatting,
|
||||
and different log levels for different components.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""Colored formatter for console output"""
|
||||
|
||||
# ANSI color codes
|
||||
COLORS = {
|
||||
'DEBUG': '\033[36m', # Cyan
|
||||
'INFO': '\033[32m', # Green
|
||||
'WARNING': '\033[33m', # Yellow
|
||||
'ERROR': '\033[31m', # Red
|
||||
'CRITICAL': '\033[35m', # Magenta
|
||||
'RESET': '\033[0m' # Reset
|
||||
}
|
||||
|
||||
def format(self, record):
|
||||
# Add color to levelname
|
||||
if record.levelname in self.COLORS:
|
||||
record.levelname = f"{self.COLORS[record.levelname]}{record.levelname}{self.COLORS['RESET']}"
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class USDAVisionLogger:
|
||||
"""Custom logger setup for the USDA Vision Camera System"""
|
||||
|
||||
def __init__(self, log_level: str = "INFO", log_file: Optional[str] = None,
|
||||
enable_console: bool = True, enable_rotation: bool = True):
|
||||
self.log_level = log_level.upper()
|
||||
self.log_file = log_file
|
||||
self.enable_console = enable_console
|
||||
self.enable_rotation = enable_rotation
|
||||
|
||||
# Setup logging
|
||||
self._setup_logging()
|
||||
|
||||
def _setup_logging(self) -> None:
|
||||
"""Setup comprehensive logging configuration"""
|
||||
|
||||
# Get root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, self.log_level))
|
||||
|
||||
# Clear existing handlers
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Create formatters
|
||||
detailed_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
|
||||
simple_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
colored_formatter = ColoredFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
# Console handler
|
||||
if self.enable_console:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, self.log_level))
|
||||
console_handler.setFormatter(colored_formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
if self.log_file:
|
||||
try:
|
||||
# Create log directory if it doesn't exist
|
||||
log_dir = os.path.dirname(self.log_file)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
if self.enable_rotation:
|
||||
# Rotating file handler (10MB max, keep 5 backups)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_file,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5
|
||||
)
|
||||
else:
|
||||
file_handler = logging.FileHandler(self.log_file)
|
||||
|
||||
file_handler.setLevel(logging.DEBUG) # File gets all messages
|
||||
file_handler.setFormatter(detailed_formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not setup file logging: {e}")
|
||||
|
||||
# Setup specific logger levels for different components
|
||||
self._setup_component_loggers()
|
||||
|
||||
# Log the logging setup
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Logging initialized - Level: {self.log_level}, File: {self.log_file}")
|
||||
|
||||
def _setup_component_loggers(self) -> None:
|
||||
"""Setup specific log levels for different components"""
|
||||
|
||||
# MQTT client - can be verbose
|
||||
mqtt_logger = logging.getLogger('usda_vision_system.mqtt')
|
||||
if self.log_level == 'DEBUG':
|
||||
mqtt_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
mqtt_logger.setLevel(logging.INFO)
|
||||
|
||||
# Camera components - important for debugging
|
||||
camera_logger = logging.getLogger('usda_vision_system.camera')
|
||||
camera_logger.setLevel(logging.INFO)
|
||||
|
||||
# API server - can be noisy
|
||||
api_logger = logging.getLogger('usda_vision_system.api')
|
||||
if self.log_level == 'DEBUG':
|
||||
api_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
api_logger.setLevel(logging.INFO)
|
||||
|
||||
# Uvicorn - reduce noise unless debugging
|
||||
uvicorn_logger = logging.getLogger('uvicorn')
|
||||
if self.log_level == 'DEBUG':
|
||||
uvicorn_logger.setLevel(logging.INFO)
|
||||
else:
|
||||
uvicorn_logger.setLevel(logging.WARNING)
|
||||
|
||||
# FastAPI - reduce noise
|
||||
fastapi_logger = logging.getLogger('fastapi')
|
||||
fastapi_logger.setLevel(logging.WARNING)
|
||||
|
||||
@staticmethod
|
||||
def setup_exception_logging():
|
||||
"""Setup logging for uncaught exceptions"""
|
||||
|
||||
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
# Don't log keyboard interrupts
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
return
|
||||
|
||||
logger = logging.getLogger("uncaught_exception")
|
||||
logger.critical(
|
||||
"Uncaught exception",
|
||||
exc_info=(exc_type, exc_value, exc_traceback)
|
||||
)
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
|
||||
class PerformanceLogger:
|
||||
"""Logger for performance monitoring"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.logger = logging.getLogger(f"performance.{name}")
|
||||
self.start_time: Optional[float] = None
|
||||
|
||||
def start_timer(self, operation: str) -> None:
|
||||
"""Start timing an operation"""
|
||||
import time
|
||||
self.start_time = time.time()
|
||||
self.logger.debug(f"Started: {operation}")
|
||||
|
||||
def end_timer(self, operation: str) -> float:
|
||||
"""End timing an operation and log duration"""
|
||||
import time
|
||||
if self.start_time is None:
|
||||
self.logger.warning(f"Timer not started for: {operation}")
|
||||
return 0.0
|
||||
|
||||
duration = time.time() - self.start_time
|
||||
self.logger.info(f"Completed: {operation} in {duration:.3f}s")
|
||||
self.start_time = None
|
||||
return duration
|
||||
|
||||
def log_metric(self, metric_name: str, value: float, unit: str = "") -> None:
|
||||
"""Log a performance metric"""
|
||||
self.logger.info(f"Metric: {metric_name} = {value} {unit}")
|
||||
|
||||
|
||||
class ErrorTracker:
|
||||
"""Track and log errors with context"""
|
||||
|
||||
def __init__(self, component_name: str):
|
||||
self.component_name = component_name
|
||||
self.logger = logging.getLogger(f"errors.{component_name}")
|
||||
self.error_count = 0
|
||||
self.last_error_time: Optional[datetime] = None
|
||||
|
||||
def log_error(self, error: Exception, context: str = "",
|
||||
additional_data: Optional[dict] = None) -> None:
|
||||
"""Log an error with context and tracking"""
|
||||
self.error_count += 1
|
||||
self.last_error_time = datetime.now()
|
||||
|
||||
error_msg = f"Error in {self.component_name}"
|
||||
if context:
|
||||
error_msg += f" ({context})"
|
||||
error_msg += f": {str(error)}"
|
||||
|
||||
if additional_data:
|
||||
error_msg += f" | Data: {additional_data}"
|
||||
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
|
||||
def log_warning(self, message: str, context: str = "") -> None:
|
||||
"""Log a warning with context"""
|
||||
warning_msg = f"Warning in {self.component_name}"
|
||||
if context:
|
||||
warning_msg += f" ({context})"
|
||||
warning_msg += f": {message}"
|
||||
|
||||
self.logger.warning(warning_msg)
|
||||
|
||||
def get_error_stats(self) -> dict:
|
||||
"""Get error statistics"""
|
||||
return {
|
||||
"component": self.component_name,
|
||||
"error_count": self.error_count,
|
||||
"last_error_time": self.last_error_time.isoformat() if self.last_error_time else None
|
||||
}
|
||||
|
||||
|
||||
def setup_logging(log_level: str = "INFO", log_file: Optional[str] = None) -> USDAVisionLogger:
|
||||
"""Setup logging for the entire application"""
|
||||
|
||||
# Setup main logging
|
||||
logger_setup = USDAVisionLogger(
|
||||
log_level=log_level,
|
||||
log_file=log_file,
|
||||
enable_console=True,
|
||||
enable_rotation=True
|
||||
)
|
||||
|
||||
# Setup exception logging
|
||||
USDAVisionLogger.setup_exception_logging()
|
||||
|
||||
return logger_setup
|
||||
|
||||
|
||||
def get_performance_logger(component_name: str) -> PerformanceLogger:
|
||||
"""Get a performance logger for a component"""
|
||||
return PerformanceLogger(component_name)
|
||||
|
||||
|
||||
def get_error_tracker(component_name: str) -> ErrorTracker:
|
||||
"""Get an error tracker for a component"""
|
||||
return ErrorTracker(component_name)
|
||||
370
camera-management-api/usda_vision_system/core/state_manager.py
Normal file
370
camera-management-api/usda_vision_system/core/state_manager.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
State management for the USDA Vision Camera System.
|
||||
|
||||
This module manages the current state of machines, cameras, and recordings
|
||||
in a thread-safe manner.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, Optional, List, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MachineState(Enum):
|
||||
"""Machine states"""
|
||||
|
||||
UNKNOWN = "unknown"
|
||||
ON = "on"
|
||||
OFF = "off"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class CameraStatus(Enum):
|
||||
"""Camera status"""
|
||||
|
||||
UNKNOWN = "unknown"
|
||||
AVAILABLE = "available"
|
||||
BUSY = "busy"
|
||||
ERROR = "error"
|
||||
DISCONNECTED = "disconnected"
|
||||
|
||||
|
||||
class RecordingState(Enum):
|
||||
"""Recording states"""
|
||||
|
||||
IDLE = "idle"
|
||||
RECORDING = "recording"
|
||||
STOPPING = "stopping"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MachineInfo:
|
||||
"""Machine state information"""
|
||||
|
||||
name: str
|
||||
state: MachineState = MachineState.UNKNOWN
|
||||
last_updated: datetime = field(default_factory=datetime.now)
|
||||
last_message: Optional[str] = None
|
||||
mqtt_topic: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MQTTEvent:
|
||||
"""MQTT event information for history tracking"""
|
||||
|
||||
machine_name: str
|
||||
topic: str
|
||||
payload: str
|
||||
normalized_state: str
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
message_number: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraInfo:
|
||||
"""Camera state information"""
|
||||
|
||||
name: str
|
||||
status: CameraStatus = CameraStatus.UNKNOWN
|
||||
last_checked: datetime = field(default_factory=datetime.now)
|
||||
last_error: Optional[str] = None
|
||||
device_info: Optional[Dict[str, Any]] = None
|
||||
is_recording: bool = False
|
||||
current_recording_file: Optional[str] = None
|
||||
recording_start_time: Optional[datetime] = None
|
||||
|
||||
# Auto-recording status
|
||||
auto_recording_enabled: bool = False
|
||||
auto_recording_active: bool = False # Whether auto-recording is currently managing this camera
|
||||
auto_recording_failure_count: int = 0
|
||||
auto_recording_last_attempt: Optional[datetime] = None
|
||||
auto_recording_last_error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingInfo:
|
||||
"""Recording session information"""
|
||||
|
||||
camera_name: str
|
||||
filename: str
|
||||
start_time: datetime
|
||||
state: RecordingState = RecordingState.RECORDING
|
||||
end_time: Optional[datetime] = None
|
||||
file_size_bytes: Optional[int] = None
|
||||
frame_count: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Thread-safe state manager for the entire system"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# State dictionaries
|
||||
self._machines: Dict[str, MachineInfo] = {}
|
||||
self._cameras: Dict[str, CameraInfo] = {}
|
||||
self._recordings: Dict[str, RecordingInfo] = {} # Key: recording_id (filename)
|
||||
|
||||
# MQTT event history
|
||||
self._mqtt_events: List[MQTTEvent] = []
|
||||
self._mqtt_event_counter = 0
|
||||
self._max_mqtt_events = 100 # Keep last 100 events
|
||||
|
||||
# System state
|
||||
self._mqtt_connected = False
|
||||
self._system_started = False
|
||||
self._last_mqtt_message_time: Optional[datetime] = None
|
||||
|
||||
# Machine state management
|
||||
def update_machine_state(self, name: str, state: str, message: Optional[str] = None, topic: Optional[str] = None) -> bool:
|
||||
"""Update machine state"""
|
||||
try:
|
||||
machine_state = MachineState(state.lower())
|
||||
except ValueError:
|
||||
self.logger.warning(f"Invalid machine state: {state}")
|
||||
machine_state = MachineState.UNKNOWN
|
||||
|
||||
with self._lock:
|
||||
if name not in self._machines:
|
||||
self._machines[name] = MachineInfo(name=name, mqtt_topic=topic)
|
||||
|
||||
machine = self._machines[name]
|
||||
old_state = machine.state
|
||||
machine.state = machine_state
|
||||
machine.last_updated = datetime.now()
|
||||
machine.last_message = message
|
||||
if topic:
|
||||
machine.mqtt_topic = topic
|
||||
|
||||
self.logger.info(f"Machine {name} state: {old_state.value} -> {machine_state.value}")
|
||||
return old_state != machine_state
|
||||
|
||||
def get_machine_state(self, name: str) -> Optional[MachineInfo]:
|
||||
"""Get machine state"""
|
||||
with self._lock:
|
||||
return self._machines.get(name)
|
||||
|
||||
def get_all_machines(self) -> Dict[str, MachineInfo]:
|
||||
"""Get all machine states"""
|
||||
with self._lock:
|
||||
return self._machines.copy()
|
||||
|
||||
# MQTT event management
|
||||
def add_mqtt_event(self, machine_name: str, topic: str, payload: str, normalized_state: str) -> None:
|
||||
"""Add an MQTT event to the history"""
|
||||
with self._lock:
|
||||
self._mqtt_event_counter += 1
|
||||
|
||||
event = MQTTEvent(machine_name=machine_name, topic=topic, payload=payload, normalized_state=normalized_state, timestamp=datetime.now(), message_number=self._mqtt_event_counter)
|
||||
|
||||
self._mqtt_events.append(event)
|
||||
|
||||
# Keep only the last N events
|
||||
if len(self._mqtt_events) > self._max_mqtt_events:
|
||||
self._mqtt_events.pop(0)
|
||||
|
||||
self.logger.debug(f"Added MQTT event #{self._mqtt_event_counter}: {machine_name} -> {normalized_state}")
|
||||
|
||||
def get_recent_mqtt_events(self, limit: int = 5) -> List[MQTTEvent]:
|
||||
"""Get the most recent MQTT events"""
|
||||
with self._lock:
|
||||
# Return the last 'limit' events in reverse chronological order (newest first)
|
||||
return list(reversed(self._mqtt_events[-limit:]))
|
||||
|
||||
def get_mqtt_event_count(self) -> int:
|
||||
"""Get total number of MQTT events processed"""
|
||||
with self._lock:
|
||||
return self._mqtt_event_counter
|
||||
|
||||
# Camera state management
|
||||
def update_camera_status(self, name: str, status: str, error: Optional[str] = None, device_info: Optional[Dict] = None) -> bool:
|
||||
"""Update camera status"""
|
||||
try:
|
||||
camera_status = CameraStatus(status.lower())
|
||||
except ValueError:
|
||||
self.logger.warning(f"Invalid camera status: {status}")
|
||||
camera_status = CameraStatus.UNKNOWN
|
||||
|
||||
with self._lock:
|
||||
if name not in self._cameras:
|
||||
self._cameras[name] = CameraInfo(name=name)
|
||||
|
||||
camera = self._cameras[name]
|
||||
old_status = camera.status
|
||||
camera.status = camera_status
|
||||
camera.last_checked = datetime.now()
|
||||
camera.last_error = error
|
||||
if device_info:
|
||||
camera.device_info = device_info
|
||||
|
||||
if old_status != camera_status:
|
||||
self.logger.info(f"Camera {name} status: {old_status.value} -> {camera_status.value}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_camera_recording(self, name: str, recording: bool, filename: Optional[str] = None) -> None:
|
||||
"""Set camera recording state"""
|
||||
with self._lock:
|
||||
if name not in self._cameras:
|
||||
self._cameras[name] = CameraInfo(name=name)
|
||||
|
||||
camera = self._cameras[name]
|
||||
camera.is_recording = recording
|
||||
camera.current_recording_file = filename
|
||||
|
||||
if recording and filename:
|
||||
camera.recording_start_time = datetime.now()
|
||||
self.logger.info(f"Camera {name} started recording: {filename}")
|
||||
elif not recording:
|
||||
camera.recording_start_time = None
|
||||
self.logger.info(f"Camera {name} stopped recording")
|
||||
|
||||
def get_camera_status(self, name: str) -> Optional[CameraInfo]:
|
||||
"""Get camera status"""
|
||||
with self._lock:
|
||||
return self._cameras.get(name)
|
||||
|
||||
def get_all_cameras(self) -> Dict[str, CameraInfo]:
|
||||
"""Get all camera statuses"""
|
||||
with self._lock:
|
||||
return self._cameras.copy()
|
||||
|
||||
# Recording management
|
||||
def start_recording(self, camera_name: str, filename: str) -> str:
|
||||
"""Start a new recording session"""
|
||||
recording_id = filename # Use filename as recording ID
|
||||
|
||||
with self._lock:
|
||||
recording = RecordingInfo(camera_name=camera_name, filename=filename, start_time=datetime.now())
|
||||
self._recordings[recording_id] = recording
|
||||
|
||||
# Update camera state
|
||||
self.set_camera_recording(camera_name, True, filename)
|
||||
|
||||
self.logger.info(f"Started recording session: {recording_id}")
|
||||
return recording_id
|
||||
|
||||
def stop_recording(self, recording_id: str, file_size: Optional[int] = None, frame_count: Optional[int] = None) -> bool:
|
||||
"""Stop a recording session"""
|
||||
with self._lock:
|
||||
if recording_id not in self._recordings:
|
||||
self.logger.warning(f"Recording session not found: {recording_id}")
|
||||
return False
|
||||
|
||||
recording = self._recordings[recording_id]
|
||||
recording.state = RecordingState.IDLE
|
||||
recording.end_time = datetime.now()
|
||||
recording.file_size_bytes = file_size
|
||||
recording.frame_count = frame_count
|
||||
|
||||
# Update camera state
|
||||
self.set_camera_recording(recording.camera_name, False)
|
||||
|
||||
duration = (recording.end_time - recording.start_time).total_seconds()
|
||||
self.logger.info(f"Stopped recording session: {recording_id} (duration: {duration:.1f}s)")
|
||||
return True
|
||||
|
||||
def set_recording_error(self, recording_id: str, error_message: str) -> bool:
|
||||
"""Set recording error state"""
|
||||
with self._lock:
|
||||
if recording_id not in self._recordings:
|
||||
return False
|
||||
|
||||
recording = self._recordings[recording_id]
|
||||
recording.state = RecordingState.ERROR
|
||||
recording.error_message = error_message
|
||||
recording.end_time = datetime.now()
|
||||
|
||||
# Update camera state
|
||||
self.set_camera_recording(recording.camera_name, False)
|
||||
|
||||
self.logger.error(f"Recording error for {recording_id}: {error_message}")
|
||||
return True
|
||||
|
||||
def get_recording(self, recording_id: str) -> Optional[RecordingInfo]:
|
||||
"""Get recording information"""
|
||||
with self._lock:
|
||||
return self._recordings.get(recording_id)
|
||||
|
||||
def get_all_recordings(self) -> Dict[str, RecordingInfo]:
|
||||
"""Get all recording sessions"""
|
||||
with self._lock:
|
||||
return self._recordings.copy()
|
||||
|
||||
def get_active_recordings(self) -> Dict[str, RecordingInfo]:
|
||||
"""Get currently active recordings"""
|
||||
with self._lock:
|
||||
return {rid: recording for rid, recording in self._recordings.items() if recording.state == RecordingState.RECORDING}
|
||||
|
||||
# System state management
|
||||
def set_mqtt_connected(self, connected: bool) -> None:
|
||||
"""Set MQTT connection state"""
|
||||
with self._lock:
|
||||
old_state = self._mqtt_connected
|
||||
self._mqtt_connected = connected
|
||||
if connected:
|
||||
self._last_mqtt_message_time = datetime.now()
|
||||
|
||||
if old_state != connected:
|
||||
self.logger.info(f"MQTT connection: {'connected' if connected else 'disconnected'}")
|
||||
|
||||
def is_mqtt_connected(self) -> bool:
|
||||
"""Check if MQTT is connected"""
|
||||
with self._lock:
|
||||
return self._mqtt_connected
|
||||
|
||||
def update_mqtt_activity(self) -> None:
|
||||
"""Update last MQTT message time"""
|
||||
with self._lock:
|
||||
self._last_mqtt_message_time = datetime.now()
|
||||
|
||||
def set_system_started(self, started: bool) -> None:
|
||||
"""Set system started state"""
|
||||
with self._lock:
|
||||
self._system_started = started
|
||||
self.logger.info(f"System {'started' if started else 'stopped'}")
|
||||
|
||||
def is_system_started(self) -> bool:
|
||||
"""Check if system is started"""
|
||||
with self._lock:
|
||||
return self._system_started
|
||||
|
||||
# Utility methods
|
||||
def get_system_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of the entire system state"""
|
||||
with self._lock:
|
||||
return {
|
||||
"system_started": self._system_started,
|
||||
"mqtt_connected": self._mqtt_connected,
|
||||
"last_mqtt_message": self._last_mqtt_message_time.isoformat() if self._last_mqtt_message_time else None,
|
||||
"machines": {name: {"state": machine.state.value, "last_updated": machine.last_updated.isoformat()} for name, machine in self._machines.items()},
|
||||
"cameras": {name: {"status": camera.status.value, "is_recording": camera.is_recording, "last_checked": camera.last_checked.isoformat()} for name, camera in self._cameras.items()},
|
||||
"active_recordings": len(self.get_active_recordings()),
|
||||
"total_recordings": len(self._recordings),
|
||||
}
|
||||
|
||||
def cleanup_old_recordings(self, max_age_hours: int = 24) -> int:
|
||||
"""Clean up old recording entries from memory"""
|
||||
cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
|
||||
removed_count = 0
|
||||
|
||||
with self._lock:
|
||||
to_remove = []
|
||||
for recording_id, recording in self._recordings.items():
|
||||
if recording.state != RecordingState.RECORDING and recording.end_time and recording.end_time < cutoff_time:
|
||||
to_remove.append(recording_id)
|
||||
|
||||
for recording_id in to_remove:
|
||||
del self._recordings[recording_id]
|
||||
removed_count += 1
|
||||
|
||||
if removed_count > 0:
|
||||
self.logger.info(f"Cleaned up {removed_count} old recording entries")
|
||||
|
||||
return removed_count
|
||||
225
camera-management-api/usda_vision_system/core/timezone_utils.py
Normal file
225
camera-management-api/usda_vision_system/core/timezone_utils.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Timezone utilities for the USDA Vision Camera System.
|
||||
|
||||
This module provides timezone-aware datetime handling for Atlanta, Georgia.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import pytz
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class TimezoneManager:
|
||||
"""Manages timezone-aware datetime operations"""
|
||||
|
||||
def __init__(self, timezone_name: str = "America/New_York"):
|
||||
self.timezone_name = timezone_name
|
||||
self.timezone = pytz.timezone(timezone_name)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Log timezone information
|
||||
self.logger.info(f"Timezone manager initialized for {timezone_name}")
|
||||
self._log_timezone_info()
|
||||
|
||||
def _log_timezone_info(self) -> None:
|
||||
"""Log current timezone information"""
|
||||
now = self.now()
|
||||
self.logger.info(f"Current local time: {now}")
|
||||
self.logger.info(f"Current UTC time: {self.to_utc(now)}")
|
||||
self.logger.info(f"Timezone: {now.tzname()} (UTC{now.strftime('%z')})")
|
||||
|
||||
def now(self) -> datetime.datetime:
|
||||
"""Get current time in the configured timezone"""
|
||||
return datetime.datetime.now(self.timezone)
|
||||
|
||||
def utc_now(self) -> datetime.datetime:
|
||||
"""Get current UTC time"""
|
||||
return datetime.datetime.now(pytz.UTC)
|
||||
|
||||
def to_local(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert datetime to local timezone"""
|
||||
if dt.tzinfo is None:
|
||||
# Assume UTC if no timezone info
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.astimezone(self.timezone)
|
||||
|
||||
def to_utc(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert datetime to UTC"""
|
||||
if dt.tzinfo is None:
|
||||
# Assume local timezone if no timezone info
|
||||
dt = self.timezone.localize(dt)
|
||||
return dt.astimezone(pytz.UTC)
|
||||
|
||||
def localize(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Add timezone info to naive datetime (assumes local timezone)"""
|
||||
if dt.tzinfo is not None:
|
||||
return dt
|
||||
return self.timezone.localize(dt)
|
||||
|
||||
def format_timestamp(self, dt: Optional[datetime.datetime] = None,
|
||||
include_timezone: bool = True) -> str:
|
||||
"""Format datetime as timestamp string"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
if include_timezone:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
else:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def format_filename_timestamp(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format datetime for use in filenames (no special characters)"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
def parse_timestamp(self, timestamp_str: str) -> datetime.datetime:
|
||||
"""Parse timestamp string to datetime"""
|
||||
try:
|
||||
# Try parsing with timezone info
|
||||
return datetime.datetime.fromisoformat(timestamp_str)
|
||||
except ValueError:
|
||||
try:
|
||||
# Try parsing without timezone (assume local)
|
||||
dt = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
return self.localize(dt)
|
||||
except ValueError:
|
||||
try:
|
||||
# Try parsing filename format
|
||||
dt = datetime.datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
|
||||
return self.localize(dt)
|
||||
except ValueError:
|
||||
raise ValueError(f"Unable to parse timestamp: {timestamp_str}")
|
||||
|
||||
def is_dst(self, dt: Optional[datetime.datetime] = None) -> bool:
|
||||
"""Check if datetime is during daylight saving time"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return bool(dt.dst())
|
||||
|
||||
def get_timezone_offset(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Get timezone offset string (e.g., '-0500' or '-0400')"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.strftime('%z')
|
||||
|
||||
def get_timezone_name(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Get timezone name (e.g., 'EST' or 'EDT')"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.tzname()
|
||||
|
||||
|
||||
# Global timezone manager instance for Atlanta, Georgia
|
||||
atlanta_tz = TimezoneManager("America/New_York")
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def now_atlanta() -> datetime.datetime:
|
||||
"""Get current Atlanta time"""
|
||||
return atlanta_tz.now()
|
||||
|
||||
|
||||
def format_atlanta_timestamp(dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format timestamp in Atlanta timezone"""
|
||||
return atlanta_tz.format_timestamp(dt)
|
||||
|
||||
|
||||
def format_filename_timestamp(dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format timestamp for filenames"""
|
||||
return atlanta_tz.format_filename_timestamp(dt)
|
||||
|
||||
|
||||
def to_atlanta_time(dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert any datetime to Atlanta time"""
|
||||
return atlanta_tz.to_local(dt)
|
||||
|
||||
|
||||
def check_time_sync() -> dict:
|
||||
"""Check if system time appears to be synchronized"""
|
||||
import requests
|
||||
|
||||
result = {
|
||||
"system_time": now_atlanta(),
|
||||
"timezone": atlanta_tz.get_timezone_name(),
|
||||
"offset": atlanta_tz.get_timezone_offset(),
|
||||
"dst": atlanta_tz.is_dst(),
|
||||
"sync_status": "unknown",
|
||||
"time_diff_seconds": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
# Check against world time API
|
||||
response = requests.get(
|
||||
"http://worldtimeapi.org/api/timezone/America/New_York",
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
api_time = datetime.datetime.fromisoformat(data['datetime'])
|
||||
|
||||
# Convert to same timezone for comparison
|
||||
system_time = atlanta_tz.now()
|
||||
time_diff = abs((system_time.replace(tzinfo=None) -
|
||||
api_time.replace(tzinfo=None)).total_seconds())
|
||||
|
||||
result["api_time"] = api_time
|
||||
result["time_diff_seconds"] = time_diff
|
||||
|
||||
if time_diff < 5:
|
||||
result["sync_status"] = "synchronized"
|
||||
elif time_diff < 30:
|
||||
result["sync_status"] = "minor_drift"
|
||||
else:
|
||||
result["sync_status"] = "out_of_sync"
|
||||
else:
|
||||
result["error"] = f"API returned status {response.status_code}"
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def log_time_info(logger: Optional[logging.Logger] = None) -> None:
|
||||
"""Log comprehensive time information"""
|
||||
if logger is None:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sync_info = check_time_sync()
|
||||
|
||||
logger.info("=== TIME SYNCHRONIZATION STATUS ===")
|
||||
logger.info(f"System time: {sync_info['system_time']}")
|
||||
logger.info(f"Timezone: {sync_info['timezone']} ({sync_info['offset']})")
|
||||
logger.info(f"Daylight Saving: {'Yes' if sync_info['dst'] else 'No'}")
|
||||
logger.info(f"Sync status: {sync_info['sync_status']}")
|
||||
|
||||
if sync_info.get('time_diff_seconds') is not None:
|
||||
logger.info(f"Time difference: {sync_info['time_diff_seconds']:.2f} seconds")
|
||||
|
||||
if sync_info.get('error'):
|
||||
logger.warning(f"Time sync check error: {sync_info['error']}")
|
||||
|
||||
logger.info("=====================================")
|
||||
266
camera-management-api/usda_vision_system/main.py
Normal file
266
camera-management-api/usda_vision_system/main.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Main Application Coordinator for the USDA Vision Camera System.
|
||||
|
||||
This module coordinates all system components and provides graceful startup/shutdown.
|
||||
"""
|
||||
|
||||
import signal
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from .core.config import Config
|
||||
from .core.state_manager import StateManager
|
||||
from .core.events import EventSystem, EventType
|
||||
from .core.logging_config import setup_logging, get_error_tracker, get_performance_logger
|
||||
from .core.timezone_utils import log_time_info, check_time_sync
|
||||
from .mqtt.client import MQTTClient
|
||||
from .camera.manager import CameraManager
|
||||
from .storage.manager import StorageManager
|
||||
from .recording.standalone_auto_recorder import StandaloneAutoRecorder
|
||||
from .api.server import APIServer
|
||||
|
||||
|
||||
class USDAVisionSystem:
|
||||
"""Main application coordinator for the USDA Vision Camera System"""
|
||||
|
||||
def __init__(self, config_file: Optional[str] = None):
|
||||
# Load configuration first (basic logging will be used initially)
|
||||
self.config = Config(config_file)
|
||||
|
||||
# Setup comprehensive logging
|
||||
self.logger_setup = setup_logging(log_level=self.config.system.log_level, log_file=self.config.system.log_file)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Setup error tracking and performance monitoring
|
||||
self.error_tracker = get_error_tracker("main_system")
|
||||
self.performance_logger = get_performance_logger("main_system")
|
||||
|
||||
# Initialize core components
|
||||
self.state_manager = StateManager()
|
||||
self.event_system = EventSystem()
|
||||
|
||||
# Initialize system components
|
||||
self.storage_manager = StorageManager(self.config, self.state_manager, self.event_system)
|
||||
self.mqtt_client = MQTTClient(self.config, self.state_manager, self.event_system)
|
||||
self.camera_manager = CameraManager(self.config, self.state_manager, self.event_system)
|
||||
self.auto_recording_manager = StandaloneAutoRecorder(config=self.config)
|
||||
self.api_server = APIServer(self.config, self.state_manager, self.event_system, self.camera_manager, self.mqtt_client, self.storage_manager, self.auto_recording_manager)
|
||||
|
||||
# System state
|
||||
self.running = False
|
||||
self.start_time: Optional[datetime] = None
|
||||
|
||||
# Setup signal handlers for graceful shutdown
|
||||
self._setup_signal_handlers()
|
||||
|
||||
self.logger.info("USDA Vision Camera System initialized")
|
||||
|
||||
def _setup_signal_handlers(self) -> None:
|
||||
"""Setup signal handlers for graceful shutdown"""
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
self.logger.info(f"Received signal {signum}, initiating graceful shutdown...")
|
||||
self.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the entire system"""
|
||||
if self.running:
|
||||
self.logger.warning("System is already running")
|
||||
return True
|
||||
|
||||
self.logger.info("Starting USDA Vision Camera System...")
|
||||
self.performance_logger.start_timer("system_startup")
|
||||
self.start_time = datetime.now()
|
||||
|
||||
# Check time synchronization
|
||||
self.logger.info("Checking time synchronization...")
|
||||
log_time_info(self.logger)
|
||||
sync_info = check_time_sync()
|
||||
if sync_info["sync_status"] == "out_of_sync":
|
||||
self.error_tracker.log_warning(f"System time may be out of sync (difference: {sync_info.get('time_diff_seconds', 'unknown')}s)", "time_sync_check")
|
||||
elif sync_info["sync_status"] == "synchronized":
|
||||
self.logger.info("✅ System time is synchronized")
|
||||
|
||||
try:
|
||||
# Start storage manager (no background tasks)
|
||||
self.logger.info("Initializing storage manager...")
|
||||
try:
|
||||
# Verify storage integrity
|
||||
integrity_report = self.storage_manager.verify_storage_integrity()
|
||||
if integrity_report.get("fixed_issues", 0) > 0:
|
||||
self.logger.info(f"Fixed {integrity_report['fixed_issues']} storage integrity issues")
|
||||
self.logger.info("Storage manager ready")
|
||||
except Exception as e:
|
||||
self.error_tracker.log_error(e, "storage_manager_init")
|
||||
self.logger.error("Failed to initialize storage manager")
|
||||
return False
|
||||
|
||||
# Start MQTT client
|
||||
self.logger.info("Starting MQTT client...")
|
||||
try:
|
||||
if not self.mqtt_client.start():
|
||||
self.error_tracker.log_error(Exception("MQTT client failed to start"), "mqtt_startup")
|
||||
return False
|
||||
self.logger.info("MQTT client started successfully")
|
||||
except Exception as e:
|
||||
self.error_tracker.log_error(e, "mqtt_startup")
|
||||
return False
|
||||
|
||||
# Start camera manager
|
||||
self.logger.info("Starting camera manager...")
|
||||
try:
|
||||
if not self.camera_manager.start():
|
||||
self.error_tracker.log_error(Exception("Camera manager failed to start"), "camera_startup")
|
||||
self.mqtt_client.stop()
|
||||
return False
|
||||
self.logger.info("Camera manager started successfully")
|
||||
except Exception as e:
|
||||
self.error_tracker.log_error(e, "camera_startup")
|
||||
self.mqtt_client.stop()
|
||||
return False
|
||||
|
||||
# Start auto-recording manager
|
||||
self.logger.info("Starting auto-recording manager...")
|
||||
try:
|
||||
if not self.auto_recording_manager.start():
|
||||
self.error_tracker.log_warning("Failed to start auto-recording manager", "auto_recording_startup")
|
||||
else:
|
||||
self.logger.info("Auto-recording manager started successfully")
|
||||
except Exception as e:
|
||||
self.error_tracker.log_error(e, "auto_recording_startup")
|
||||
self.logger.warning("Auto-recording manager failed to start (continuing without auto-recording)")
|
||||
|
||||
# Start API server
|
||||
self.logger.info("Starting API server...")
|
||||
try:
|
||||
if not self.api_server.start():
|
||||
self.error_tracker.log_warning("Failed to start API server", "api_startup")
|
||||
else:
|
||||
self.logger.info("API server started successfully")
|
||||
except Exception as e:
|
||||
self.error_tracker.log_error(e, "api_startup")
|
||||
self.logger.warning("API server failed to start (continuing without API)")
|
||||
|
||||
# Update system state
|
||||
self.running = True
|
||||
self.state_manager.set_system_started(True)
|
||||
|
||||
# Publish system started event
|
||||
self.event_system.publish(EventType.SYSTEM_SHUTDOWN, "main_system", {"action": "started", "timestamp": self.start_time.isoformat()}) # We don't have SYSTEM_STARTED, using closest
|
||||
|
||||
startup_time = self.performance_logger.end_timer("system_startup")
|
||||
self.logger.info(f"USDA Vision Camera System started successfully in {startup_time:.2f}s")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.error_tracker.log_error(e, "system_startup")
|
||||
self.stop()
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the entire system gracefully"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.logger.info("Stopping USDA Vision Camera System...")
|
||||
self.running = False
|
||||
|
||||
try:
|
||||
# Update system state
|
||||
self.state_manager.set_system_started(False)
|
||||
|
||||
# Publish system shutdown event
|
||||
self.event_system.publish(EventType.SYSTEM_SHUTDOWN, "main_system", {"action": "stopping", "timestamp": datetime.now().isoformat()})
|
||||
|
||||
# Stop API server
|
||||
self.api_server.stop()
|
||||
|
||||
# Stop auto-recording manager
|
||||
self.auto_recording_manager.stop()
|
||||
|
||||
# Stop camera manager (this will stop all recordings)
|
||||
self.camera_manager.stop()
|
||||
|
||||
# Stop MQTT client
|
||||
self.mqtt_client.stop()
|
||||
|
||||
# Final cleanup
|
||||
if self.start_time:
|
||||
uptime = (datetime.now() - self.start_time).total_seconds()
|
||||
self.logger.info(f"System uptime: {uptime:.1f} seconds")
|
||||
|
||||
self.logger.info("USDA Vision Camera System stopped")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during system shutdown: {e}")
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the system (blocking call)"""
|
||||
if not self.start():
|
||||
self.logger.error("Failed to start system")
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info("System running... Press Ctrl+C to stop")
|
||||
|
||||
# Main loop - just keep the system alive
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
|
||||
# Periodic maintenance tasks could go here
|
||||
# For example: cleanup old recordings, health checks, etc.
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.logger.info("Keyboard interrupt received")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error in main loop: {e}")
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def get_system_status(self) -> dict:
|
||||
"""Get comprehensive system status"""
|
||||
return {
|
||||
"running": self.running,
|
||||
"start_time": self.start_time.isoformat() if self.start_time else None,
|
||||
"uptime_seconds": (datetime.now() - self.start_time).total_seconds() if self.start_time else 0,
|
||||
"components": {"mqtt_client": {"running": self.mqtt_client.is_running(), "connected": self.mqtt_client.is_connected()}, "camera_manager": {"running": self.camera_manager.is_running()}, "api_server": {"running": self.api_server.is_running()}},
|
||||
"state_summary": self.state_manager.get_system_summary(),
|
||||
}
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if system is running"""
|
||||
return self.running
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the application"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="USDA Vision Camera System")
|
||||
parser.add_argument("--config", type=str, help="Path to configuration file", default="config.json")
|
||||
parser.add_argument("--log-level", type=str, choices=["DEBUG", "INFO", "WARNING", "ERROR"], help="Override log level", default=None)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create and run system
|
||||
system = USDAVisionSystem(args.config)
|
||||
|
||||
# Override log level if specified
|
||||
if args.log_level:
|
||||
logging.getLogger().setLevel(getattr(logging, args.log_level))
|
||||
|
||||
try:
|
||||
system.run()
|
||||
except Exception as e:
|
||||
logging.error(f"Fatal error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
camera-management-api/usda_vision_system/mqtt/__init__.py
Normal file
11
camera-management-api/usda_vision_system/mqtt/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
MQTT module for the USDA Vision Camera System.
|
||||
|
||||
This module handles MQTT communication for receiving machine state updates
|
||||
and triggering camera recording based on machine states.
|
||||
"""
|
||||
|
||||
from .client import MQTTClient
|
||||
from .handlers import MQTTMessageHandler
|
||||
|
||||
__all__ = ["MQTTClient", "MQTTMessageHandler"]
|
||||
267
camera-management-api/usda_vision_system/mqtt/client.py
Normal file
267
camera-management-api/usda_vision_system/mqtt/client.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
MQTT Client for the USDA Vision Camera System.
|
||||
|
||||
This module provides MQTT connectivity and message handling for machine state updates.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from ..core.config import Config, MQTTConfig
|
||||
from ..core.state_manager import StateManager
|
||||
from ..core.events import EventSystem, EventType, publish_machine_state_changed
|
||||
from .handlers import MQTTMessageHandler
|
||||
|
||||
|
||||
class MQTTClient:
|
||||
"""MQTT client for receiving machine state updates"""
|
||||
|
||||
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem):
|
||||
self.config = config
|
||||
self.mqtt_config = config.mqtt
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# MQTT client
|
||||
self.client: Optional[mqtt.Client] = None
|
||||
self.connected = False
|
||||
self.running = False
|
||||
|
||||
# Threading
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Message handler
|
||||
self.message_handler = MQTTMessageHandler(state_manager, event_system)
|
||||
|
||||
# Connection retry settings
|
||||
self.reconnect_delay = 5 # seconds
|
||||
self.max_reconnect_attempts = 10
|
||||
|
||||
# Topic mapping (topic -> machine_name)
|
||||
self.topic_to_machine = {topic: machine_name for machine_name, topic in self.mqtt_config.topics.items()}
|
||||
|
||||
# Status tracking
|
||||
self.start_time = None
|
||||
self.message_count = 0
|
||||
self.error_count = 0
|
||||
self.last_message_time = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the MQTT client in a separate thread"""
|
||||
if self.running:
|
||||
self.logger.warning("MQTT client is already running")
|
||||
return True
|
||||
|
||||
self.logger.info("Starting MQTT client...")
|
||||
self.running = True
|
||||
self._stop_event.clear()
|
||||
self.start_time = time.time()
|
||||
|
||||
# Start in separate thread
|
||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
# Wait a moment to see if connection succeeds
|
||||
time.sleep(2)
|
||||
return self.connected
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the MQTT client"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.logger.info("Stopping MQTT client...")
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self.client and self.connected:
|
||||
self.client.disconnect()
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
self.logger.info("MQTT client stopped")
|
||||
|
||||
def _run_loop(self) -> None:
|
||||
"""Main MQTT client loop"""
|
||||
reconnect_attempts = 0
|
||||
|
||||
while self.running and not self._stop_event.is_set():
|
||||
try:
|
||||
if not self.connected:
|
||||
if self._connect():
|
||||
reconnect_attempts = 0
|
||||
else:
|
||||
reconnect_attempts += 1
|
||||
if reconnect_attempts >= self.max_reconnect_attempts:
|
||||
self.logger.error(f"Max reconnection attempts ({self.max_reconnect_attempts}) reached")
|
||||
break
|
||||
|
||||
self.logger.warning(f"Reconnection attempt {reconnect_attempts}/{self.max_reconnect_attempts} in {self.reconnect_delay}s")
|
||||
if self._stop_event.wait(self.reconnect_delay):
|
||||
break
|
||||
continue
|
||||
|
||||
# Process MQTT messages
|
||||
if self.client:
|
||||
self.client.loop(timeout=1.0)
|
||||
|
||||
# Small delay to prevent busy waiting
|
||||
if self._stop_event.wait(0.1):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in MQTT loop: {e}")
|
||||
self.connected = False
|
||||
if self._stop_event.wait(self.reconnect_delay):
|
||||
break
|
||||
|
||||
self.running = False
|
||||
self.logger.info("MQTT client loop ended")
|
||||
|
||||
def _connect(self) -> bool:
|
||||
"""Connect to MQTT broker"""
|
||||
try:
|
||||
# Create new client instance
|
||||
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
|
||||
|
||||
# Set callbacks
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
self.client.on_message = self._on_message
|
||||
|
||||
# Set authentication if provided
|
||||
if self.mqtt_config.username and self.mqtt_config.password:
|
||||
self.client.username_pw_set(self.mqtt_config.username, self.mqtt_config.password)
|
||||
|
||||
# Connect to broker
|
||||
self.logger.info(f"Connecting to MQTT broker at {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
|
||||
self.client.connect(self.mqtt_config.broker_host, self.mqtt_config.broker_port, 60)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
return False
|
||||
|
||||
def _subscribe_to_topics(self) -> None:
|
||||
"""Subscribe to all configured topics"""
|
||||
if not self.client or not self.connected:
|
||||
return
|
||||
|
||||
for machine_name, topic in self.mqtt_config.topics.items():
|
||||
try:
|
||||
result, mid = self.client.subscribe(topic)
|
||||
if result == mqtt.MQTT_ERR_SUCCESS:
|
||||
self.logger.info(f"📋 MQTT SUBSCRIBED: {topic} (machine: {machine_name})")
|
||||
print(f"📋 MQTT SUBSCRIBED: {machine_name} → {topic}")
|
||||
else:
|
||||
self.logger.error(f"❌ MQTT SUBSCRIPTION FAILED: {topic}")
|
||||
print(f"❌ MQTT SUBSCRIPTION FAILED: {topic}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error subscribing to topic {topic}: {e}")
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc) -> None:
|
||||
"""Callback for when the client connects to the broker"""
|
||||
if rc == 0:
|
||||
self.connected = True
|
||||
self.state_manager.set_mqtt_connected(True)
|
||||
self.event_system.publish(EventType.MQTT_CONNECTED, "mqtt_client")
|
||||
self.logger.info("🔗 MQTT CONNECTED to broker successfully")
|
||||
print(f"🔗 MQTT CONNECTED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
|
||||
|
||||
# Subscribe to topics immediately after connection
|
||||
self._subscribe_to_topics()
|
||||
else:
|
||||
self.connected = False
|
||||
self.logger.error(f"❌ MQTT CONNECTION FAILED with return code {rc}")
|
||||
print(f"❌ MQTT CONNECTION FAILED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port} (code: {rc})")
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc) -> None:
|
||||
"""Callback for when the client disconnects from the broker"""
|
||||
self.connected = False
|
||||
self.state_manager.set_mqtt_connected(False)
|
||||
self.event_system.publish(EventType.MQTT_DISCONNECTED, "mqtt_client")
|
||||
|
||||
if rc != 0:
|
||||
self.logger.warning(f"⚠️ MQTT DISCONNECTED unexpectedly (rc: {rc})")
|
||||
print(f"⚠️ MQTT DISCONNECTED: Unexpected disconnection (code: {rc})")
|
||||
else:
|
||||
self.logger.info("🔌 MQTT DISCONNECTED gracefully")
|
||||
print("🔌 MQTT DISCONNECTED: Graceful disconnection")
|
||||
|
||||
def _on_message(self, client, userdata, msg) -> None:
|
||||
"""Callback for when a message is received"""
|
||||
try:
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode("utf-8").strip()
|
||||
|
||||
self.logger.debug(f"MQTT message received - Topic: {topic}, Payload: {payload}")
|
||||
|
||||
# Update MQTT activity and tracking
|
||||
self.state_manager.update_mqtt_activity()
|
||||
self.message_count += 1
|
||||
self.last_message_time = time.time()
|
||||
|
||||
# Get machine name from topic
|
||||
machine_name = self.topic_to_machine.get(topic)
|
||||
if not machine_name:
|
||||
self.logger.warning(f"❓ MQTT UNKNOWN TOPIC: {topic}")
|
||||
print(f"❓ MQTT UNKNOWN TOPIC: {topic}")
|
||||
return
|
||||
|
||||
# Show MQTT message on console
|
||||
print(f"📡 MQTT MESSAGE: {machine_name} → {payload}")
|
||||
|
||||
# Handle the message
|
||||
self.message_handler.handle_message(machine_name, topic, payload)
|
||||
|
||||
except Exception as e:
|
||||
self.error_count += 1
|
||||
self.logger.error(f"Error processing MQTT message: {e}")
|
||||
|
||||
def publish_message(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
|
||||
"""Publish a message to MQTT broker"""
|
||||
if not self.client or not self.connected:
|
||||
self.logger.warning("Cannot publish: MQTT client not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = self.client.publish(topic, payload, qos, retain)
|
||||
if result.rc == mqtt.MQTT_ERR_SUCCESS:
|
||||
self.logger.debug(f"Published message to {topic}: {payload}")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Failed to publish message to {topic}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error publishing message: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get MQTT client status"""
|
||||
uptime_seconds = None
|
||||
last_message_time_str = None
|
||||
|
||||
if self.start_time:
|
||||
uptime_seconds = time.time() - self.start_time
|
||||
|
||||
if self.last_message_time:
|
||||
from datetime import datetime
|
||||
|
||||
last_message_time_str = datetime.fromtimestamp(self.last_message_time).isoformat()
|
||||
|
||||
return {"connected": self.connected, "running": self.running, "broker_host": self.mqtt_config.broker_host, "broker_port": self.mqtt_config.broker_port, "subscribed_topics": list(self.mqtt_config.topics.values()), "topic_mappings": self.topic_to_machine, "message_count": self.message_count, "error_count": self.error_count, "last_message_time": last_message_time_str, "uptime_seconds": uptime_seconds}
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if MQTT client is connected"""
|
||||
return self.connected
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if MQTT client is running"""
|
||||
return self.running
|
||||
142
camera-management-api/usda_vision_system/mqtt/handlers.py
Normal file
142
camera-management-api/usda_vision_system/mqtt/handlers.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
MQTT Message Handlers for the USDA Vision Camera System.
|
||||
|
||||
This module handles processing of MQTT messages and triggering appropriate actions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..core.state_manager import StateManager, MachineState
|
||||
from ..core.events import EventSystem, publish_machine_state_changed
|
||||
|
||||
|
||||
class MQTTMessageHandler:
|
||||
"""Handles MQTT messages and triggers appropriate system actions"""
|
||||
|
||||
def __init__(self, state_manager: StateManager, event_system: EventSystem):
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Message processing statistics
|
||||
self.message_count = 0
|
||||
self.last_message_time: Optional[datetime] = None
|
||||
self.error_count = 0
|
||||
|
||||
def handle_message(self, machine_name: str, topic: str, payload: str) -> None:
|
||||
"""Handle an incoming MQTT message"""
|
||||
try:
|
||||
self.message_count += 1
|
||||
self.last_message_time = datetime.now()
|
||||
|
||||
self.logger.info(f"Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: {payload}")
|
||||
|
||||
# Normalize payload
|
||||
normalized_payload = self._normalize_payload(payload)
|
||||
|
||||
# Update machine state
|
||||
state_changed = self.state_manager.update_machine_state(name=machine_name, state=normalized_payload, message=payload, topic=topic)
|
||||
|
||||
# Store MQTT event in history
|
||||
self.state_manager.add_mqtt_event(machine_name=machine_name, topic=topic, payload=payload, normalized_state=normalized_payload)
|
||||
|
||||
# Publish state change event if state actually changed
|
||||
if state_changed:
|
||||
publish_machine_state_changed(machine_name=machine_name, state=normalized_payload, source="mqtt_handler")
|
||||
|
||||
self.logger.info(f"Machine {machine_name} state changed to: {normalized_payload}")
|
||||
|
||||
# Log the message for debugging
|
||||
self._log_message_details(machine_name, topic, payload, normalized_payload)
|
||||
|
||||
except Exception as e:
|
||||
self.error_count += 1
|
||||
self.logger.error(f"Error handling MQTT message for {machine_name}: {e}")
|
||||
|
||||
def _normalize_payload(self, payload: str) -> str:
|
||||
"""Normalize payload to standard machine states"""
|
||||
payload_lower = payload.lower().strip()
|
||||
|
||||
# Map various possible payloads to standard states
|
||||
if payload_lower in ["on", "true", "1", "start", "running", "active"]:
|
||||
return "on"
|
||||
elif payload_lower in ["off", "false", "0", "stop", "stopped", "inactive"]:
|
||||
return "off"
|
||||
elif payload_lower in ["error", "fault", "alarm"]:
|
||||
return "error"
|
||||
else:
|
||||
# For unknown payloads, log and return as-is
|
||||
self.logger.warning(f"Unknown payload format: '{payload}', treating as raw state")
|
||||
return payload_lower
|
||||
|
||||
def _log_message_details(self, machine_name: str, topic: str, original_payload: str, normalized_payload: str) -> None:
|
||||
"""Log detailed message information"""
|
||||
self.logger.debug(f"MQTT Message Details:")
|
||||
self.logger.debug(f" Machine: {machine_name}")
|
||||
self.logger.debug(f" Topic: {topic}")
|
||||
self.logger.debug(f" Original Payload: '{original_payload}'")
|
||||
self.logger.debug(f" Normalized Payload: '{normalized_payload}'")
|
||||
self.logger.debug(f" Timestamp: {self.last_message_time}")
|
||||
self.logger.debug(f" Total Messages Processed: {self.message_count}")
|
||||
|
||||
def get_statistics(self) -> Dict[str, any]:
|
||||
"""Get message processing statistics"""
|
||||
return {"total_messages": self.message_count, "error_count": self.error_count, "last_message_time": self.last_message_time.isoformat() if self.last_message_time else None, "success_rate": (self.message_count - self.error_count) / max(self.message_count, 1) * 100}
|
||||
|
||||
def reset_statistics(self) -> None:
|
||||
"""Reset message processing statistics"""
|
||||
self.message_count = 0
|
||||
self.error_count = 0
|
||||
self.last_message_time = None
|
||||
self.logger.info("MQTT message handler statistics reset")
|
||||
|
||||
|
||||
class MachineStateProcessor:
|
||||
"""Processes machine state changes and determines actions"""
|
||||
|
||||
def __init__(self, state_manager: StateManager, event_system: EventSystem):
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def process_state_change(self, machine_name: str, old_state: str, new_state: str) -> None:
|
||||
"""Process a machine state change and determine what actions to take"""
|
||||
self.logger.info(f"Processing state change for {machine_name}: {old_state} -> {new_state}")
|
||||
|
||||
# Handle state transitions
|
||||
if old_state != "on" and new_state == "on":
|
||||
self._handle_machine_turned_on(machine_name)
|
||||
elif old_state == "on" and new_state != "on":
|
||||
self._handle_machine_turned_off(machine_name)
|
||||
elif new_state == "error":
|
||||
self._handle_machine_error(machine_name)
|
||||
|
||||
def _handle_machine_turned_on(self, machine_name: str) -> None:
|
||||
"""Handle machine turning on - should start recording"""
|
||||
self.logger.info(f"Machine {machine_name} turned ON - should start recording")
|
||||
|
||||
# The actual recording start will be handled by the camera manager
|
||||
# which listens to the MACHINE_STATE_CHANGED event
|
||||
|
||||
# We could add additional logic here, such as:
|
||||
# - Checking if camera is available
|
||||
# - Pre-warming camera settings
|
||||
# - Sending notifications
|
||||
|
||||
def _handle_machine_turned_off(self, machine_name: str) -> None:
|
||||
"""Handle machine turning off - should stop recording"""
|
||||
self.logger.info(f"Machine {machine_name} turned OFF - should stop recording")
|
||||
|
||||
# The actual recording stop will be handled by the camera manager
|
||||
# which listens to the MACHINE_STATE_CHANGED event
|
||||
|
||||
def _handle_machine_error(self, machine_name: str) -> None:
|
||||
"""Handle machine error state"""
|
||||
self.logger.warning(f"Machine {machine_name} in ERROR state")
|
||||
|
||||
# Could implement error handling logic here:
|
||||
# - Stop recording if active
|
||||
# - Send alerts
|
||||
# - Log error details
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Recording module for the USDA Vision Camera System.
|
||||
|
||||
This module contains components for managing automatic recording
|
||||
based on machine state changes.
|
||||
"""
|
||||
|
||||
from .auto_manager import AutoRecordingManager
|
||||
|
||||
__all__ = ["AutoRecordingManager"]
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Auto-Recording Manager for the USDA Vision Camera System.
|
||||
|
||||
This module manages automatic recording start/stop based on machine state changes
|
||||
received via MQTT. It includes retry logic for failed recording attempts and
|
||||
tracks auto-recording status for each camera.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..core.config import Config, CameraConfig
|
||||
from ..core.state_manager import StateManager, MachineState
|
||||
from ..core.events import EventSystem, EventType, Event
|
||||
from ..core.timezone_utils import format_filename_timestamp
|
||||
|
||||
|
||||
class AutoRecordingManager:
|
||||
"""Manages automatic recording based on machine state changes"""
|
||||
|
||||
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager):
|
||||
self.config = config
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.camera_manager = camera_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Threading
|
||||
self.running = False
|
||||
self._retry_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Track retry attempts for each camera
|
||||
self._retry_queue: Dict[str, Dict[str, Any]] = {} # camera_name -> retry_info
|
||||
self._retry_lock = threading.RLock()
|
||||
|
||||
# Subscribe to machine state change events
|
||||
self.event_system.subscribe(EventType.MACHINE_STATE_CHANGED, self._on_machine_state_changed)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the auto-recording manager"""
|
||||
if self.running:
|
||||
self.logger.warning("Auto-recording manager is already running")
|
||||
return True
|
||||
|
||||
if not self.config.system.auto_recording_enabled:
|
||||
self.logger.info("Auto-recording is disabled in system configuration")
|
||||
return True
|
||||
|
||||
self.logger.info("Starting auto-recording manager...")
|
||||
self.running = True
|
||||
self._stop_event.clear()
|
||||
|
||||
# Initialize camera auto-recording status
|
||||
self._initialize_camera_status()
|
||||
|
||||
# Start retry thread
|
||||
self._retry_thread = threading.Thread(target=self._retry_loop, daemon=True)
|
||||
self._retry_thread.start()
|
||||
|
||||
self.logger.info("Auto-recording manager started successfully")
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the auto-recording manager"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.logger.info("Stopping auto-recording manager...")
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
# Wait for retry thread to finish
|
||||
if self._retry_thread and self._retry_thread.is_alive():
|
||||
self._retry_thread.join(timeout=5)
|
||||
|
||||
self.logger.info("Auto-recording manager stopped")
|
||||
|
||||
def _initialize_camera_status(self) -> None:
|
||||
"""Initialize auto-recording status for all cameras"""
|
||||
for camera_config in self.config.cameras:
|
||||
if camera_config.enabled and camera_config.auto_start_recording_enabled:
|
||||
# Update camera status in state manager
|
||||
camera_info = self.state_manager.get_camera_status(camera_config.name)
|
||||
if camera_info:
|
||||
camera_info.auto_recording_enabled = True
|
||||
self.logger.info(f"Auto-recording enabled for camera {camera_config.name}")
|
||||
else:
|
||||
# Create camera info if it doesn't exist
|
||||
self.state_manager.update_camera_status(camera_config.name, "unknown")
|
||||
camera_info = self.state_manager.get_camera_status(camera_config.name)
|
||||
if camera_info:
|
||||
camera_info.auto_recording_enabled = True
|
||||
self.logger.info(f"Auto-recording enabled for camera {camera_config.name}")
|
||||
|
||||
def _on_machine_state_changed(self, event: Event) -> None:
|
||||
"""Handle machine state change events"""
|
||||
try:
|
||||
machine_name = event.data.get("machine_name")
|
||||
new_state = event.data.get("state")
|
||||
|
||||
if not machine_name or not new_state:
|
||||
self.logger.warning(f"Invalid event data - machine_name: {machine_name}, state: {new_state}")
|
||||
return
|
||||
|
||||
self.logger.info(f"Machine state changed: {machine_name} -> {new_state}")
|
||||
|
||||
# Find cameras associated with this machine
|
||||
associated_cameras = self._get_cameras_for_machine(machine_name)
|
||||
|
||||
for camera_config in associated_cameras:
|
||||
if not camera_config.enabled or not camera_config.auto_start_recording_enabled:
|
||||
self.logger.debug(f"Skipping camera {camera_config.name} - not enabled or auto recording disabled")
|
||||
continue
|
||||
|
||||
if new_state.lower() == "on":
|
||||
self._handle_machine_on(camera_config)
|
||||
elif new_state.lower() == "off":
|
||||
self._handle_machine_off(camera_config)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling machine state change: {e}")
|
||||
|
||||
def _get_cameras_for_machine(self, machine_name: str) -> list[CameraConfig]:
|
||||
"""Get all cameras associated with a machine topic"""
|
||||
associated_cameras = []
|
||||
|
||||
# Map machine names to topics
|
||||
machine_topic_map = {"vibratory_conveyor": "vibratory_conveyor", "blower_separator": "blower_separator"}
|
||||
|
||||
machine_topic = machine_topic_map.get(machine_name)
|
||||
if not machine_topic:
|
||||
return associated_cameras
|
||||
|
||||
for camera_config in self.config.cameras:
|
||||
if camera_config.machine_topic == machine_topic:
|
||||
associated_cameras.append(camera_config)
|
||||
|
||||
return associated_cameras
|
||||
|
||||
def _handle_machine_on(self, camera_config: CameraConfig) -> None:
|
||||
"""Handle machine turning on - start recording"""
|
||||
camera_name = camera_config.name
|
||||
|
||||
# Check if camera is already recording
|
||||
camera_info = self.state_manager.get_camera_status(camera_name)
|
||||
if camera_info and camera_info.is_recording:
|
||||
self.logger.info(f"Camera {camera_name} is already recording, skipping auto-start")
|
||||
return
|
||||
|
||||
self.logger.info(f"Machine turned ON - attempting to start recording for camera {camera_name}")
|
||||
|
||||
# Update auto-recording status
|
||||
if camera_info:
|
||||
camera_info.auto_recording_active = True
|
||||
camera_info.auto_recording_last_attempt = datetime.now()
|
||||
else:
|
||||
# Create camera info if it doesn't exist
|
||||
self.state_manager.update_camera_status(camera_name, "unknown")
|
||||
camera_info = self.state_manager.get_camera_status(camera_name)
|
||||
if camera_info:
|
||||
camera_info.auto_recording_active = True
|
||||
camera_info.auto_recording_last_attempt = datetime.now()
|
||||
|
||||
# Attempt to start recording
|
||||
success = self._start_recording_for_camera(camera_config)
|
||||
|
||||
if not success:
|
||||
# Add to retry queue
|
||||
self._add_to_retry_queue(camera_config, "start")
|
||||
|
||||
def _handle_machine_off(self, camera_config: CameraConfig) -> None:
|
||||
"""Handle machine turning off - stop recording"""
|
||||
camera_name = camera_config.name
|
||||
|
||||
self.logger.info(f"Machine turned OFF - attempting to stop recording for camera {camera_name}")
|
||||
|
||||
# Update auto-recording status
|
||||
camera_info = self.state_manager.get_camera_status(camera_name)
|
||||
if camera_info:
|
||||
camera_info.auto_recording_active = False
|
||||
|
||||
# Remove from retry queue if present
|
||||
with self._retry_lock:
|
||||
if camera_name in self._retry_queue:
|
||||
del self._retry_queue[camera_name]
|
||||
|
||||
# Attempt to stop recording
|
||||
self._stop_recording_for_camera(camera_config)
|
||||
|
||||
def _start_recording_for_camera(self, camera_config: CameraConfig) -> bool:
|
||||
"""Start recording for a specific camera using its default configuration"""
|
||||
try:
|
||||
camera_name = camera_config.name
|
||||
|
||||
# Generate filename with timestamp and machine info
|
||||
timestamp = format_filename_timestamp()
|
||||
machine_name = camera_config.machine_topic.replace("_", "-")
|
||||
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{camera_config.video_format}"
|
||||
|
||||
# Use camera manager to start recording with the camera's default configuration
|
||||
# Pass the camera's configured settings from config.json
|
||||
success = self.camera_manager.manual_start_recording(camera_name=camera_name, filename=filename, exposure_ms=camera_config.exposure_ms, gain=camera_config.gain, fps=camera_config.target_fps)
|
||||
|
||||
if success:
|
||||
self.logger.info(f"Successfully started auto-recording for camera {camera_name}: {filename}")
|
||||
self.logger.info(f"Using camera settings - Exposure: {camera_config.exposure_ms}ms, Gain: {camera_config.gain}, FPS: {camera_config.target_fps}")
|
||||
|
||||
# Update status
|
||||
camera_info = self.state_manager.get_camera_status(camera_name)
|
||||
if camera_info:
|
||||
camera_info.auto_recording_failure_count = 0
|
||||
camera_info.auto_recording_last_error = None
|
||||
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Failed to start auto-recording for camera {camera_name}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error starting auto-recording for camera {camera_config.name}: {e}")
|
||||
|
||||
# Update error status
|
||||
camera_info = self.state_manager.get_camera_status(camera_config.name)
|
||||
if camera_info:
|
||||
camera_info.auto_recording_last_error = str(e)
|
||||
|
||||
return False
|
||||
|
||||
def _stop_recording_for_camera(self, camera_config: CameraConfig) -> bool:
|
||||
"""Stop recording for a specific camera"""
|
||||
try:
|
||||
camera_name = camera_config.name
|
||||
|
||||
# Use camera manager to stop recording
|
||||
success = self.camera_manager.manual_stop_recording(camera_name)
|
||||
|
||||
if success:
|
||||
self.logger.info(f"Successfully stopped auto-recording for camera {camera_name}")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"Failed to stop auto-recording for camera {camera_name} (may not have been recording)")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping auto-recording for camera {camera_config.name}: {e}")
|
||||
return False
|
||||
|
||||
def _add_to_retry_queue(self, camera_config: CameraConfig, action: str) -> None:
|
||||
"""Add a camera to the retry queue"""
|
||||
with self._retry_lock:
|
||||
camera_name = camera_config.name
|
||||
|
||||
retry_info = {"camera_config": camera_config, "action": action, "attempt_count": 0, "next_retry_time": datetime.now() + timedelta(seconds=camera_config.auto_recording_retry_delay_seconds), "max_retries": camera_config.auto_recording_max_retries}
|
||||
|
||||
self._retry_queue[camera_name] = retry_info
|
||||
self.logger.info(f"Added camera {camera_name} to retry queue for {action} (max retries: {retry_info['max_retries']})")
|
||||
|
||||
def _retry_loop(self) -> None:
|
||||
"""Background thread to handle retry attempts"""
|
||||
while self.running and not self._stop_event.is_set():
|
||||
try:
|
||||
current_time = datetime.now()
|
||||
cameras_to_retry = []
|
||||
|
||||
# Find cameras ready for retry
|
||||
with self._retry_lock:
|
||||
for camera_name, retry_info in list(self._retry_queue.items()):
|
||||
if current_time >= retry_info["next_retry_time"]:
|
||||
cameras_to_retry.append((camera_name, retry_info))
|
||||
|
||||
# Process retries
|
||||
for camera_name, retry_info in cameras_to_retry:
|
||||
self._process_retry(camera_name, retry_info)
|
||||
|
||||
# Sleep for a short interval
|
||||
self._stop_event.wait(1)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in retry loop: {e}")
|
||||
self._stop_event.wait(5)
|
||||
|
||||
def _process_retry(self, camera_name: str, retry_info: Dict[str, Any]) -> None:
|
||||
"""Process a retry attempt for a camera"""
|
||||
try:
|
||||
retry_info["attempt_count"] += 1
|
||||
camera_config = retry_info["camera_config"]
|
||||
action = retry_info["action"]
|
||||
|
||||
self.logger.info(f"Retry attempt {retry_info['attempt_count']}/{retry_info['max_retries']} for camera {camera_name} ({action})")
|
||||
|
||||
# Update camera status
|
||||
camera_info = self.state_manager.get_camera_status(camera_name)
|
||||
if camera_info:
|
||||
camera_info.auto_recording_last_attempt = datetime.now()
|
||||
camera_info.auto_recording_failure_count = retry_info["attempt_count"]
|
||||
|
||||
# Attempt the action
|
||||
success = False
|
||||
if action == "start":
|
||||
success = self._start_recording_for_camera(camera_config)
|
||||
|
||||
if success:
|
||||
# Success - remove from retry queue
|
||||
with self._retry_lock:
|
||||
if camera_name in self._retry_queue:
|
||||
del self._retry_queue[camera_name]
|
||||
self.logger.info(f"Retry successful for camera {camera_name}")
|
||||
else:
|
||||
# Failed - check if we should retry again
|
||||
if retry_info["attempt_count"] >= retry_info["max_retries"]:
|
||||
# Max retries reached
|
||||
with self._retry_lock:
|
||||
if camera_name in self._retry_queue:
|
||||
del self._retry_queue[camera_name]
|
||||
|
||||
error_msg = f"Max retry attempts ({retry_info['max_retries']}) reached for camera {camera_name}"
|
||||
self.logger.error(error_msg)
|
||||
|
||||
# Update camera status
|
||||
if camera_info:
|
||||
camera_info.auto_recording_last_error = error_msg
|
||||
camera_info.auto_recording_active = False
|
||||
else:
|
||||
# Schedule next retry
|
||||
retry_info["next_retry_time"] = datetime.now() + timedelta(seconds=camera_config.auto_recording_retry_delay_seconds)
|
||||
self.logger.info(f"Scheduling next retry for camera {camera_name} in {camera_config.auto_recording_retry_delay_seconds} seconds")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing retry for camera {camera_name}: {e}")
|
||||
|
||||
# Remove from retry queue on error
|
||||
with self._retry_lock:
|
||||
if camera_name in self._retry_queue:
|
||||
del self._retry_queue[camera_name]
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get auto-recording manager status"""
|
||||
with self._retry_lock:
|
||||
retry_queue_status = {camera_name: {"action": info["action"], "attempt_count": info["attempt_count"], "max_retries": info["max_retries"], "next_retry_time": info["next_retry_time"].isoformat()} for camera_name, info in self._retry_queue.items()}
|
||||
|
||||
return {"running": self.running, "auto_recording_enabled": self.config.system.auto_recording_enabled, "retry_queue": retry_queue_status, "enabled_cameras": [camera.name for camera in self.config.cameras if camera.enabled and camera.auto_start_recording_enabled]}
|
||||
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone Auto-Recording System for USDA Vision Cameras
|
||||
|
||||
This is a simplified, reliable auto-recording system that:
|
||||
1. Monitors MQTT messages directly
|
||||
2. Starts/stops camera recordings based on machine state
|
||||
3. Works independently of the main system
|
||||
4. Is easy to debug and maintain
|
||||
|
||||
Usage:
|
||||
sudo python -m usda_vision_system.recording.standalone_auto_recorder
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
# Add the project root to the path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from usda_vision_system.core.config import Config
|
||||
from usda_vision_system.camera.recorder import CameraRecorder
|
||||
from usda_vision_system.core.state_manager import StateManager
|
||||
from usda_vision_system.core.events import EventSystem
|
||||
|
||||
|
||||
class StandaloneAutoRecorder:
|
||||
"""Standalone auto-recording system that monitors MQTT and controls cameras directly"""
|
||||
|
||||
def __init__(self, config_path: str = "config.json", config: Optional[Config] = None):
|
||||
# Load configuration
|
||||
if config:
|
||||
self.config = config
|
||||
else:
|
||||
self.config = Config(config_path)
|
||||
|
||||
# Setup logging (only if not already configured)
|
||||
if not logging.getLogger().handlers:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[logging.FileHandler("standalone_auto_recorder.log"), logging.StreamHandler()])
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize components
|
||||
self.state_manager = StateManager()
|
||||
self.event_system = EventSystem()
|
||||
|
||||
# MQTT client
|
||||
self.mqtt_client: Optional[mqtt.Client] = None
|
||||
|
||||
# Camera recorders
|
||||
self.camera_recorders: Dict[str, CameraRecorder] = {}
|
||||
self.active_recordings: Dict[str, str] = {} # camera_name -> filename
|
||||
|
||||
# Machine to camera mapping
|
||||
self.machine_camera_map = self._build_machine_camera_map()
|
||||
|
||||
# Threading
|
||||
self.running = False
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self.logger.info("Standalone Auto-Recorder initialized")
|
||||
self.logger.info(f"Machine-Camera mapping: {self.machine_camera_map}")
|
||||
|
||||
def _build_machine_camera_map(self) -> Dict[str, str]:
|
||||
"""Build mapping from machine topics to camera names"""
|
||||
mapping = {}
|
||||
for camera_config in self.config.cameras:
|
||||
if camera_config.enabled and camera_config.auto_start_recording_enabled:
|
||||
machine_name = camera_config.machine_topic
|
||||
if machine_name:
|
||||
mapping[machine_name] = camera_config.name
|
||||
self.logger.info(f"Auto-recording enabled: {machine_name} -> {camera_config.name}")
|
||||
return mapping
|
||||
|
||||
def _setup_mqtt(self) -> bool:
|
||||
"""Setup MQTT client"""
|
||||
try:
|
||||
self.mqtt_client = mqtt.Client()
|
||||
self.mqtt_client.on_connect = self._on_mqtt_connect
|
||||
self.mqtt_client.on_message = self._on_mqtt_message
|
||||
self.mqtt_client.on_disconnect = self._on_mqtt_disconnect
|
||||
|
||||
# Connect to MQTT broker
|
||||
self.logger.info(f"Connecting to MQTT broker at {self.config.mqtt.broker_host}:{self.config.mqtt.broker_port}")
|
||||
self.mqtt_client.connect(self.config.mqtt.broker_host, self.config.mqtt.broker_port, 60)
|
||||
|
||||
# Start MQTT loop in background
|
||||
self.mqtt_client.loop_start()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to setup MQTT: {e}")
|
||||
return False
|
||||
|
||||
def _on_mqtt_connect(self, client, userdata, flags, rc):
|
||||
"""MQTT connection callback"""
|
||||
if rc == 0:
|
||||
self.logger.info("Connected to MQTT broker")
|
||||
|
||||
# Subscribe to machine state topics
|
||||
for machine_name in self.machine_camera_map.keys():
|
||||
if hasattr(self.config.mqtt, "topics") and self.config.mqtt.topics:
|
||||
topic = self.config.mqtt.topics.get(machine_name)
|
||||
if topic:
|
||||
client.subscribe(topic)
|
||||
self.logger.info(f"Subscribed to: {topic}")
|
||||
else:
|
||||
self.logger.warning(f"No MQTT topic configured for machine: {machine_name}")
|
||||
else:
|
||||
# Fallback to default topic format
|
||||
topic = f"vision/{machine_name}/state"
|
||||
client.subscribe(topic)
|
||||
self.logger.info(f"Subscribed to: {topic} (default format)")
|
||||
else:
|
||||
self.logger.error(f"Failed to connect to MQTT broker: {rc}")
|
||||
|
||||
def _on_mqtt_disconnect(self, client, userdata, rc):
|
||||
"""MQTT disconnection callback"""
|
||||
self.logger.warning(f"Disconnected from MQTT broker: {rc}")
|
||||
|
||||
def _on_mqtt_message(self, client, userdata, msg):
|
||||
"""MQTT message callback"""
|
||||
try:
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode("utf-8").strip().lower()
|
||||
|
||||
# Extract machine name from topic (vision/{machine_name}/state)
|
||||
topic_parts = topic.split("/")
|
||||
if len(topic_parts) >= 3 and topic_parts[0] == "vision" and topic_parts[2] == "state":
|
||||
machine_name = topic_parts[1]
|
||||
|
||||
self.logger.info(f"MQTT: {machine_name} -> {payload}")
|
||||
|
||||
# Handle state change
|
||||
self._handle_machine_state_change(machine_name, payload)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing MQTT message: {e}")
|
||||
|
||||
def _handle_machine_state_change(self, machine_name: str, state: str):
|
||||
"""Handle machine state change"""
|
||||
try:
|
||||
# Check if we have a camera for this machine
|
||||
camera_name = self.machine_camera_map.get(machine_name)
|
||||
if not camera_name:
|
||||
return
|
||||
|
||||
self.logger.info(f"Handling state change: {machine_name} ({camera_name}) -> {state}")
|
||||
|
||||
if state == "on":
|
||||
self._start_recording(camera_name, machine_name)
|
||||
elif state == "off":
|
||||
self._stop_recording(camera_name, machine_name)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling machine state change: {e}")
|
||||
|
||||
def _start_recording(self, camera_name: str, machine_name: str):
|
||||
"""Start recording for a camera"""
|
||||
try:
|
||||
# Check if already recording
|
||||
if camera_name in self.active_recordings:
|
||||
self.logger.warning(f"Camera {camera_name} is already recording")
|
||||
return
|
||||
|
||||
# Get or create camera recorder
|
||||
recorder = self._get_camera_recorder(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Failed to get recorder for camera {camera_name}")
|
||||
return
|
||||
|
||||
# Generate filename with timestamp and machine info
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
camera_config = self.config.get_camera_by_name(camera_name)
|
||||
video_format = camera_config.video_format if camera_config else "mp4"
|
||||
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{video_format}"
|
||||
|
||||
# Start recording
|
||||
success = recorder.start_recording(filename)
|
||||
if success:
|
||||
self.active_recordings[camera_name] = filename
|
||||
self.logger.info(f"✅ Started recording: {camera_name} -> {filename}")
|
||||
else:
|
||||
self.logger.error(f"❌ Failed to start recording for camera {camera_name}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error starting recording for {camera_name}: {e}")
|
||||
|
||||
def _stop_recording(self, camera_name: str, machine_name: str):
|
||||
"""Stop recording for a camera"""
|
||||
try:
|
||||
# Check if recording
|
||||
if camera_name not in self.active_recordings:
|
||||
self.logger.warning(f"Camera {camera_name} is not recording")
|
||||
return
|
||||
|
||||
# Get recorder
|
||||
recorder = self._get_camera_recorder(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Failed to get recorder for camera {camera_name}")
|
||||
return
|
||||
|
||||
# Stop recording
|
||||
filename = self.active_recordings.pop(camera_name)
|
||||
success = recorder.stop_recording()
|
||||
|
||||
if success:
|
||||
self.logger.info(f"✅ Stopped recording: {camera_name} -> {filename}")
|
||||
else:
|
||||
self.logger.error(f"❌ Failed to stop recording for camera {camera_name}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping recording for {camera_name}: {e}")
|
||||
|
||||
def _get_camera_recorder(self, camera_name: str) -> Optional[CameraRecorder]:
|
||||
"""Get or create camera recorder"""
|
||||
try:
|
||||
# Return existing recorder
|
||||
if camera_name in self.camera_recorders:
|
||||
return self.camera_recorders[camera_name]
|
||||
|
||||
# Find camera config
|
||||
camera_config = None
|
||||
for config in self.config.cameras:
|
||||
if config.name == camera_name:
|
||||
camera_config = config
|
||||
break
|
||||
|
||||
if not camera_config:
|
||||
self.logger.error(f"No configuration found for camera {camera_name}")
|
||||
return None
|
||||
|
||||
# Find device info (simplified camera discovery)
|
||||
device_info = self._find_camera_device(camera_name)
|
||||
if not device_info:
|
||||
self.logger.error(f"No device found for camera {camera_name}")
|
||||
return None
|
||||
|
||||
# Create recorder
|
||||
recorder = CameraRecorder(camera_config=camera_config, device_info=device_info, state_manager=self.state_manager, event_system=self.event_system)
|
||||
|
||||
self.camera_recorders[camera_name] = recorder
|
||||
self.logger.info(f"Created recorder for camera {camera_name}")
|
||||
return recorder
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating recorder for {camera_name}: {e}")
|
||||
return None
|
||||
|
||||
def _find_camera_device(self, camera_name: str):
|
||||
"""Simplified camera device discovery"""
|
||||
try:
|
||||
# Import camera SDK
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "camera_sdk"))
|
||||
import mvsdk
|
||||
|
||||
# Initialize SDK
|
||||
mvsdk.CameraSdkInit(1)
|
||||
|
||||
# Enumerate cameras
|
||||
device_list = mvsdk.CameraEnumerateDevice()
|
||||
|
||||
# For now, map by index (camera1 = index 0, camera2 = index 1)
|
||||
camera_index = int(camera_name.replace("camera", "")) - 1
|
||||
|
||||
if 0 <= camera_index < len(device_list):
|
||||
return device_list[camera_index]
|
||||
else:
|
||||
self.logger.error(f"Camera index {camera_index} not found (total: {len(device_list)})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error finding camera device: {e}")
|
||||
return None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the standalone auto-recorder"""
|
||||
try:
|
||||
self.logger.info("Starting Standalone Auto-Recorder...")
|
||||
|
||||
# Setup MQTT
|
||||
if not self._setup_mqtt():
|
||||
return False
|
||||
|
||||
# Wait for MQTT connection
|
||||
time.sleep(2)
|
||||
|
||||
self.running = True
|
||||
self.logger.info("✅ Standalone Auto-Recorder started successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start auto-recorder: {e}")
|
||||
return False
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""Stop the standalone auto-recorder"""
|
||||
try:
|
||||
self.logger.info("Stopping Standalone Auto-Recorder...")
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
# Stop all active recordings
|
||||
for camera_name in list(self.active_recordings.keys()):
|
||||
self._stop_recording(camera_name, "system_shutdown")
|
||||
|
||||
# Cleanup camera recorders
|
||||
for recorder in self.camera_recorders.values():
|
||||
try:
|
||||
recorder.cleanup()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Stop MQTT
|
||||
if self.mqtt_client:
|
||||
self.mqtt_client.loop_stop()
|
||||
self.mqtt_client.disconnect()
|
||||
|
||||
self.logger.info("✅ Standalone Auto-Recorder stopped")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping auto-recorder: {e}")
|
||||
return False
|
||||
|
||||
def run(self):
|
||||
"""Run the auto-recorder (blocking)"""
|
||||
if not self.start():
|
||||
return False
|
||||
|
||||
try:
|
||||
# Setup signal handlers
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
self.logger.info("Auto-recorder running... Press Ctrl+C to stop")
|
||||
|
||||
# Main loop
|
||||
while self.running and not self._stop_event.is_set():
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.logger.info("Received keyboard interrupt")
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals"""
|
||||
self.logger.info(f"Received signal {signum}, shutting down...")
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
recorder = StandaloneAutoRecorder()
|
||||
recorder.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Storage module for the USDA Vision Camera System.
|
||||
|
||||
This module handles file organization, management, and cleanup for recorded videos.
|
||||
"""
|
||||
|
||||
from .manager import StorageManager
|
||||
|
||||
__all__ = ["StorageManager"]
|
||||
417
camera-management-api/usda_vision_system/storage/manager.py
Normal file
417
camera-management-api/usda_vision_system/storage/manager.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Storage Manager for the USDA Vision Camera System.
|
||||
|
||||
This module handles file organization, cleanup, and management for recorded videos.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from ..core.config import Config, StorageConfig
|
||||
from ..core.state_manager import StateManager
|
||||
from ..core.events import EventSystem, EventType, Event
|
||||
|
||||
|
||||
class StorageManager:
|
||||
"""Manages storage and file organization for recorded videos"""
|
||||
|
||||
def __init__(self, config: Config, state_manager: StateManager, event_system: Optional[EventSystem] = None):
|
||||
self.config = config
|
||||
self.storage_config = config.storage
|
||||
self.state_manager = state_manager
|
||||
self.event_system = event_system
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Ensure base storage directory exists
|
||||
self._ensure_storage_structure()
|
||||
|
||||
# File tracking
|
||||
self.file_index_path = os.path.join(self.storage_config.base_path, "file_index.json")
|
||||
self.file_index = self._load_file_index()
|
||||
|
||||
# Subscribe to recording events if event system is available
|
||||
if self.event_system:
|
||||
self._setup_event_subscriptions()
|
||||
|
||||
def _ensure_storage_structure(self) -> None:
|
||||
"""Ensure storage directory structure exists"""
|
||||
try:
|
||||
# Create base storage directory
|
||||
Path(self.storage_config.base_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create camera-specific directories
|
||||
for camera_config in self.config.cameras:
|
||||
Path(camera_config.storage_path).mkdir(parents=True, exist_ok=True)
|
||||
self.logger.debug(f"Ensured storage directory: {camera_config.storage_path}")
|
||||
|
||||
self.logger.info("Storage directory structure verified")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating storage structure: {e}")
|
||||
raise
|
||||
|
||||
def _setup_event_subscriptions(self) -> None:
|
||||
"""Setup event subscriptions for recording tracking"""
|
||||
if not self.event_system:
|
||||
return
|
||||
|
||||
def on_recording_started(event: Event):
|
||||
"""Handle recording started event"""
|
||||
try:
|
||||
camera_name = event.data.get("camera_name")
|
||||
filename = event.data.get("filename")
|
||||
if camera_name and filename:
|
||||
self.register_recording_file(camera_name=camera_name, filename=filename, start_time=event.timestamp, machine_trigger=event.data.get("machine_trigger"))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling recording started event: {e}")
|
||||
|
||||
def on_recording_stopped(event: Event):
|
||||
"""Handle recording stopped event"""
|
||||
try:
|
||||
filename = event.data.get("filename")
|
||||
if filename:
|
||||
file_id = os.path.basename(filename)
|
||||
self.finalize_recording_file(file_id=file_id, end_time=event.timestamp, duration_seconds=event.data.get("duration_seconds"))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling recording stopped event: {e}")
|
||||
|
||||
# Subscribe to recording events
|
||||
self.event_system.subscribe(EventType.RECORDING_STARTED, on_recording_started)
|
||||
self.event_system.subscribe(EventType.RECORDING_STOPPED, on_recording_stopped)
|
||||
|
||||
def _load_file_index(self) -> Dict[str, Any]:
|
||||
"""Load file index from disk"""
|
||||
try:
|
||||
if os.path.exists(self.file_index_path):
|
||||
with open(self.file_index_path, "r") as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
return {"files": {}, "last_updated": None}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading file index: {e}")
|
||||
return {"files": {}, "last_updated": None}
|
||||
|
||||
def _save_file_index(self) -> None:
|
||||
"""Save file index to disk"""
|
||||
try:
|
||||
self.file_index["last_updated"] = datetime.now().isoformat()
|
||||
with open(self.file_index_path, "w") as f:
|
||||
json.dump(self.file_index, f, indent=2)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving file index: {e}")
|
||||
|
||||
def register_recording_file(self, camera_name: str, filename: str, start_time: datetime, machine_trigger: Optional[str] = None) -> str:
|
||||
"""Register a new recording file"""
|
||||
try:
|
||||
file_id = os.path.basename(filename)
|
||||
|
||||
file_info = {"camera_name": camera_name, "filename": filename, "file_id": file_id, "start_time": start_time.isoformat(), "end_time": None, "file_size_bytes": None, "duration_seconds": None, "machine_trigger": machine_trigger, "status": "recording", "created_at": datetime.now().isoformat()}
|
||||
|
||||
self.file_index["files"][file_id] = file_info
|
||||
self._save_file_index()
|
||||
|
||||
self.logger.info(f"Registered recording file: {file_id}")
|
||||
return file_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error registering recording file: {e}")
|
||||
return ""
|
||||
|
||||
def finalize_recording_file(self, file_id: str, end_time: datetime, duration_seconds: Optional[float] = None) -> bool:
|
||||
"""Finalize a recording file when recording stops"""
|
||||
try:
|
||||
if file_id not in self.file_index["files"]:
|
||||
self.logger.warning(f"Recording file not found for finalization: {file_id}")
|
||||
return False
|
||||
|
||||
file_info = self.file_index["files"][file_id]
|
||||
file_info["end_time"] = end_time.isoformat()
|
||||
file_info["status"] = "completed"
|
||||
|
||||
if duration_seconds is not None:
|
||||
file_info["duration_seconds"] = duration_seconds
|
||||
|
||||
# Get file size if file exists
|
||||
filename = file_info["filename"]
|
||||
if os.path.exists(filename):
|
||||
file_info["file_size_bytes"] = os.path.getsize(filename)
|
||||
|
||||
self._save_file_index()
|
||||
self.logger.info(f"Finalized recording file: {file_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error finalizing recording file: {e}")
|
||||
return False
|
||||
|
||||
def finalize_recording_file(self, file_id: str, end_time: datetime, duration_seconds: float, frame_count: Optional[int] = None) -> bool:
|
||||
"""Finalize a recording file after recording stops"""
|
||||
try:
|
||||
if file_id not in self.file_index["files"]:
|
||||
self.logger.warning(f"File ID not found in index: {file_id}")
|
||||
return False
|
||||
|
||||
file_info = self.file_index["files"][file_id]
|
||||
filename = file_info["filename"]
|
||||
|
||||
# Update file information
|
||||
file_info["end_time"] = end_time.isoformat()
|
||||
file_info["duration_seconds"] = duration_seconds
|
||||
file_info["status"] = "completed"
|
||||
|
||||
# Get file size if file exists
|
||||
if os.path.exists(filename):
|
||||
file_info["file_size_bytes"] = os.path.getsize(filename)
|
||||
|
||||
if frame_count is not None:
|
||||
file_info["frame_count"] = frame_count
|
||||
|
||||
self._save_file_index()
|
||||
|
||||
self.logger.info(f"Finalized recording file: {file_id} (duration: {duration_seconds:.1f}s)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error finalizing recording file: {e}")
|
||||
return False
|
||||
|
||||
def get_recording_files(self, camera_name: Optional[str] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Get list of recording files with optional filters"""
|
||||
try:
|
||||
files = []
|
||||
|
||||
# First, get files from the index (if available)
|
||||
indexed_files = set()
|
||||
for file_id, file_info in self.file_index["files"].items():
|
||||
# Filter by camera name
|
||||
if camera_name and file_info["camera_name"] != camera_name:
|
||||
continue
|
||||
|
||||
# Filter by date range
|
||||
if start_date or end_date:
|
||||
file_start = datetime.fromisoformat(file_info["start_time"])
|
||||
if start_date and file_start < start_date:
|
||||
continue
|
||||
if end_date and file_start > end_date:
|
||||
continue
|
||||
|
||||
files.append(file_info.copy())
|
||||
indexed_files.add(file_info["filename"])
|
||||
|
||||
# Then, scan filesystem for files not in the index
|
||||
for camera_config in self.config.cameras:
|
||||
# Skip if filtering by camera name and this isn't the one
|
||||
if camera_name and camera_config.name != camera_name:
|
||||
continue
|
||||
|
||||
storage_path = Path(camera_config.storage_path)
|
||||
if storage_path.exists():
|
||||
# Scan for all supported video formats
|
||||
video_extensions = ["*.avi", "*.mp4", "*.webm"]
|
||||
for pattern in video_extensions:
|
||||
for video_file in storage_path.glob(pattern):
|
||||
if video_file.is_file() and str(video_file) not in indexed_files:
|
||||
# Get file stats
|
||||
stat = video_file.stat()
|
||||
file_mtime = datetime.fromtimestamp(stat.st_mtime)
|
||||
|
||||
# Apply date filters
|
||||
if start_date and file_mtime < start_date:
|
||||
continue
|
||||
if end_date and file_mtime > end_date:
|
||||
continue
|
||||
|
||||
# Create file info for unindexed file
|
||||
file_info = {"camera_name": camera_config.name, "filename": str(video_file), "file_id": video_file.name, "start_time": file_mtime.isoformat(), "end_time": None, "file_size_bytes": stat.st_size, "duration_seconds": None, "machine_trigger": None, "status": "unknown", "created_at": file_mtime.isoformat()} # We don't know if it's completed or not
|
||||
files.append(file_info)
|
||||
|
||||
# Sort by start time (newest first)
|
||||
files.sort(key=lambda x: x["start_time"], reverse=True)
|
||||
|
||||
# Apply limit
|
||||
if limit:
|
||||
files = files[:limit]
|
||||
|
||||
return files
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting recording files: {e}")
|
||||
return []
|
||||
|
||||
def get_storage_statistics(self) -> Dict[str, Any]:
|
||||
"""Get storage usage statistics"""
|
||||
try:
|
||||
stats = {"base_path": self.storage_config.base_path, "total_files": 0, "total_size_bytes": 0, "cameras": {}, "disk_usage": {}}
|
||||
|
||||
# Get disk usage for base path
|
||||
if os.path.exists(self.storage_config.base_path):
|
||||
disk_usage = shutil.disk_usage(self.storage_config.base_path)
|
||||
stats["disk_usage"] = {"total_bytes": disk_usage.total, "used_bytes": disk_usage.used, "free_bytes": disk_usage.free, "used_percent": (disk_usage.used / disk_usage.total) * 100}
|
||||
|
||||
# Scan actual filesystem for all video files
|
||||
# This ensures we count all files, not just those in the index
|
||||
for camera_config in self.config.cameras:
|
||||
camera_name = camera_config.name
|
||||
storage_path = Path(camera_config.storage_path)
|
||||
|
||||
if camera_name not in stats["cameras"]:
|
||||
stats["cameras"][camera_name] = {"file_count": 0, "total_size_bytes": 0, "total_duration_seconds": 0}
|
||||
|
||||
# Scan for video files in camera directory
|
||||
if storage_path.exists():
|
||||
# Scan for all supported video formats
|
||||
video_extensions = ["*.avi", "*.mp4", "*.webm"]
|
||||
for pattern in video_extensions:
|
||||
for video_file in storage_path.glob(pattern):
|
||||
if video_file.is_file():
|
||||
stats["total_files"] += 1
|
||||
stats["cameras"][camera_name]["file_count"] += 1
|
||||
|
||||
# Get file size
|
||||
try:
|
||||
file_size = video_file.stat().st_size
|
||||
stats["total_size_bytes"] += file_size
|
||||
stats["cameras"][camera_name]["total_size_bytes"] += file_size
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not get size for {video_file}: {e}")
|
||||
|
||||
# Add duration information from index if available
|
||||
for file_info in self.file_index["files"].values():
|
||||
camera_name = file_info["camera_name"]
|
||||
if camera_name in stats["cameras"] and file_info.get("duration_seconds"):
|
||||
duration = file_info["duration_seconds"]
|
||||
stats["cameras"][camera_name]["total_duration_seconds"] += duration
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting storage statistics: {e}")
|
||||
return {}
|
||||
|
||||
def cleanup_old_files(self, max_age_days: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Clean up old recording files"""
|
||||
if max_age_days is None:
|
||||
max_age_days = self.storage_config.cleanup_older_than_days
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=max_age_days)
|
||||
|
||||
cleanup_stats = {"files_removed": 0, "bytes_freed": 0, "errors": []}
|
||||
|
||||
try:
|
||||
files_to_remove = []
|
||||
|
||||
# Find files older than cutoff date
|
||||
for file_id, file_info in self.file_index["files"].items():
|
||||
try:
|
||||
file_start = datetime.fromisoformat(file_info["start_time"])
|
||||
if file_start < cutoff_date and file_info["status"] == "completed":
|
||||
files_to_remove.append((file_id, file_info))
|
||||
except Exception as e:
|
||||
cleanup_stats["errors"].append(f"Error parsing date for {file_id}: {e}")
|
||||
|
||||
# Remove old files
|
||||
for file_id, file_info in files_to_remove:
|
||||
try:
|
||||
filename = file_info["filename"]
|
||||
|
||||
# Remove physical file
|
||||
if os.path.exists(filename):
|
||||
file_size = os.path.getsize(filename)
|
||||
os.remove(filename)
|
||||
cleanup_stats["bytes_freed"] += file_size
|
||||
self.logger.info(f"Removed old file: {filename}")
|
||||
|
||||
# Remove from index
|
||||
del self.file_index["files"][file_id]
|
||||
cleanup_stats["files_removed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error removing file {file_id}: {e}"
|
||||
cleanup_stats["errors"].append(error_msg)
|
||||
self.logger.error(error_msg)
|
||||
|
||||
# Save updated index
|
||||
if cleanup_stats["files_removed"] > 0:
|
||||
self._save_file_index()
|
||||
|
||||
self.logger.info(f"Cleanup completed: {cleanup_stats['files_removed']} files removed, " f"{cleanup_stats['bytes_freed']} bytes freed")
|
||||
|
||||
return cleanup_stats
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during cleanup: {e}")
|
||||
cleanup_stats["errors"].append(str(e))
|
||||
return cleanup_stats
|
||||
|
||||
def get_file_info(self, file_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get information about a specific file"""
|
||||
return self.file_index["files"].get(file_id)
|
||||
|
||||
def delete_file(self, file_id: str) -> bool:
|
||||
"""Delete a specific recording file"""
|
||||
try:
|
||||
if file_id not in self.file_index["files"]:
|
||||
self.logger.warning(f"File ID not found: {file_id}")
|
||||
return False
|
||||
|
||||
file_info = self.file_index["files"][file_id]
|
||||
filename = file_info["filename"]
|
||||
|
||||
# Remove physical file
|
||||
if os.path.exists(filename):
|
||||
os.remove(filename)
|
||||
self.logger.info(f"Deleted file: {filename}")
|
||||
|
||||
# Remove from index
|
||||
del self.file_index["files"][file_id]
|
||||
self._save_file_index()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error deleting file {file_id}: {e}")
|
||||
return False
|
||||
|
||||
def verify_storage_integrity(self) -> Dict[str, Any]:
|
||||
"""Verify storage integrity and fix issues"""
|
||||
integrity_report = {"total_files_in_index": len(self.file_index["files"]), "missing_files": [], "orphaned_files": [], "corrupted_entries": [], "fixed_issues": 0}
|
||||
|
||||
try:
|
||||
# Check for missing files (in index but not on disk)
|
||||
for file_id, file_info in list(self.file_index["files"].items()):
|
||||
filename = file_info.get("filename")
|
||||
if filename and not os.path.exists(filename):
|
||||
integrity_report["missing_files"].append(file_id)
|
||||
# Remove from index
|
||||
del self.file_index["files"][file_id]
|
||||
integrity_report["fixed_issues"] += 1
|
||||
|
||||
# Check for orphaned files (on disk but not in index)
|
||||
for camera_config in self.config.cameras:
|
||||
storage_path = Path(camera_config.storage_path)
|
||||
if storage_path.exists():
|
||||
# Check for all supported video formats
|
||||
video_extensions = ["*.avi", "*.mp4", "*.webm"]
|
||||
for pattern in video_extensions:
|
||||
for video_file in storage_path.glob(pattern):
|
||||
file_id = video_file.name
|
||||
if file_id not in self.file_index["files"]:
|
||||
integrity_report["orphaned_files"].append(str(video_file))
|
||||
|
||||
# Save updated index if fixes were made
|
||||
if integrity_report["fixed_issues"] > 0:
|
||||
self._save_file_index()
|
||||
|
||||
self.logger.info(f"Storage integrity check completed: {integrity_report['fixed_issues']} issues fixed")
|
||||
|
||||
return integrity_report
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during integrity check: {e}")
|
||||
integrity_report["error"] = str(e)
|
||||
return integrity_report
|
||||
13
camera-management-api/usda_vision_system/video/__init__.py
Normal file
13
camera-management-api/usda_vision_system/video/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Video Module for USDA Vision Camera System.
|
||||
|
||||
This module provides modular video streaming, processing, and management capabilities
|
||||
following clean architecture principles.
|
||||
"""
|
||||
|
||||
from .domain.models import VideoFile, VideoMetadata, StreamRange
|
||||
from .application.video_service import VideoService
|
||||
from .application.streaming_service import StreamingService
|
||||
from .integration import VideoModule, create_video_module
|
||||
|
||||
__all__ = ["VideoFile", "VideoMetadata", "StreamRange", "VideoService", "StreamingService", "VideoModule", "create_video_module"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Video Application Layer.
|
||||
|
||||
Contains use cases and application services that orchestrate domain logic
|
||||
and coordinate between domain and infrastructure layers.
|
||||
"""
|
||||
|
||||
from .video_service import VideoService
|
||||
from .streaming_service import StreamingService
|
||||
|
||||
__all__ = [
|
||||
"VideoService",
|
||||
"StreamingService",
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Video Streaming Application Service.
|
||||
|
||||
Handles video streaming use cases including range requests and caching.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ..domain.interfaces import VideoRepository, StreamingCache
|
||||
from ..domain.models import VideoFile, StreamRange
|
||||
|
||||
|
||||
class StreamingService:
|
||||
"""Application service for video streaming"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_repository: VideoRepository,
|
||||
streaming_cache: Optional[StreamingCache] = None
|
||||
):
|
||||
self.video_repository = video_repository
|
||||
self.streaming_cache = streaming_cache
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def stream_video_range(
|
||||
self,
|
||||
file_id: str,
|
||||
range_request: Optional[StreamRange] = None
|
||||
) -> Tuple[Optional[bytes], Optional[VideoFile], Optional[StreamRange]]:
|
||||
"""
|
||||
Stream video data for a specific range.
|
||||
|
||||
Returns:
|
||||
Tuple of (data, video_file, actual_range)
|
||||
"""
|
||||
try:
|
||||
# Get video file
|
||||
video_file = await self.video_repository.get_by_id(file_id)
|
||||
if not video_file or not video_file.is_streamable:
|
||||
return None, None, None
|
||||
|
||||
# If no range specified, create range for entire file
|
||||
if range_request is None:
|
||||
range_request = StreamRange(start=0, end=video_file.file_size_bytes - 1)
|
||||
|
||||
# Validate and adjust range
|
||||
actual_range = self._validate_range(range_request, video_file.file_size_bytes)
|
||||
if not actual_range:
|
||||
return None, video_file, None
|
||||
|
||||
# Try to get from cache first
|
||||
if self.streaming_cache:
|
||||
cached_data = await self.streaming_cache.get_cached_range(file_id, actual_range)
|
||||
if cached_data:
|
||||
self.logger.debug(f"Serving cached range for {file_id}")
|
||||
return cached_data, video_file, actual_range
|
||||
|
||||
# Read from file
|
||||
data = await self.video_repository.get_file_range(video_file, actual_range)
|
||||
|
||||
# Cache the data if caching is enabled
|
||||
if self.streaming_cache and data:
|
||||
await self.streaming_cache.cache_range(file_id, actual_range, data)
|
||||
|
||||
return data, video_file, actual_range
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error streaming video range for {file_id}: {e}")
|
||||
return None, None, None
|
||||
|
||||
async def get_video_info(self, file_id: str) -> Optional[VideoFile]:
|
||||
"""Get video information for streaming"""
|
||||
try:
|
||||
video_file = await self.video_repository.get_by_id(file_id)
|
||||
if not video_file or not video_file.is_streamable:
|
||||
return None
|
||||
|
||||
return video_file
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting video info for {file_id}: {e}")
|
||||
return None
|
||||
|
||||
async def invalidate_cache(self, file_id: str) -> bool:
|
||||
"""Invalidate cached data for a video file"""
|
||||
try:
|
||||
if self.streaming_cache:
|
||||
await self.streaming_cache.invalidate_file(file_id)
|
||||
self.logger.info(f"Invalidated cache for {file_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error invalidating cache for {file_id}: {e}")
|
||||
return False
|
||||
|
||||
async def cleanup_cache(self, max_size_mb: int = 100) -> int:
|
||||
"""Clean up streaming cache"""
|
||||
try:
|
||||
if self.streaming_cache:
|
||||
return await self.streaming_cache.cleanup_cache(max_size_mb)
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cleaning up cache: {e}")
|
||||
return 0
|
||||
|
||||
def _validate_range(self, range_request: StreamRange, file_size: int) -> Optional[StreamRange]:
|
||||
"""Validate and adjust range request for file size"""
|
||||
try:
|
||||
start = range_request.start
|
||||
end = range_request.end
|
||||
|
||||
# Validate start position
|
||||
if start < 0:
|
||||
start = 0
|
||||
elif start >= file_size:
|
||||
return None
|
||||
|
||||
# Validate end position
|
||||
if end is None or end >= file_size:
|
||||
end = file_size - 1
|
||||
elif end < start:
|
||||
return None
|
||||
|
||||
return StreamRange(start=start, end=end)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating range: {e}")
|
||||
return None
|
||||
|
||||
def calculate_content_range_header(
|
||||
self,
|
||||
range_request: StreamRange,
|
||||
file_size: int
|
||||
) -> str:
|
||||
"""Calculate Content-Range header value"""
|
||||
return f"bytes {range_request.start}-{range_request.end}/{file_size}"
|
||||
|
||||
def should_use_partial_content(self, range_request: Optional[StreamRange], file_size: int) -> bool:
|
||||
"""Determine if response should use 206 Partial Content"""
|
||||
if not range_request:
|
||||
return False
|
||||
|
||||
# Use partial content if not requesting the entire file
|
||||
return not (range_request.start == 0 and range_request.end == file_size - 1)
|
||||
|
||||
async def get_optimal_chunk_size(self, file_size: int) -> int:
|
||||
"""Get optimal chunk size for streaming based on file size"""
|
||||
# Adaptive chunk sizing
|
||||
if file_size < 1024 * 1024: # < 1MB
|
||||
return 64 * 1024 # 64KB chunks
|
||||
elif file_size < 10 * 1024 * 1024: # < 10MB
|
||||
return 256 * 1024 # 256KB chunks
|
||||
elif file_size < 100 * 1024 * 1024: # < 100MB
|
||||
return 512 * 1024 # 512KB chunks
|
||||
else:
|
||||
return 1024 * 1024 # 1MB chunks for large files
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Video Application Service.
|
||||
|
||||
Orchestrates video-related use cases and business logic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..domain.interfaces import VideoRepository, MetadataExtractor, VideoConverter
|
||||
from ..domain.models import VideoFile, VideoMetadata, VideoFormat
|
||||
|
||||
|
||||
class VideoService:
|
||||
"""Application service for video management"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_repository: VideoRepository,
|
||||
metadata_extractor: MetadataExtractor,
|
||||
video_converter: VideoConverter
|
||||
):
|
||||
self.video_repository = video_repository
|
||||
self.metadata_extractor = metadata_extractor
|
||||
self.video_converter = video_converter
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_video_by_id(self, file_id: str) -> Optional[VideoFile]:
|
||||
"""Get video file by ID with metadata"""
|
||||
try:
|
||||
video_file = await self.video_repository.get_by_id(file_id)
|
||||
if not video_file:
|
||||
return None
|
||||
|
||||
# Ensure metadata is available
|
||||
if not video_file.metadata:
|
||||
await self._ensure_metadata(video_file)
|
||||
|
||||
return video_file
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting video {file_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_videos_by_camera(
|
||||
self,
|
||||
camera_name: str,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: Optional[int] = None,
|
||||
include_metadata: bool = False
|
||||
) -> List[VideoFile]:
|
||||
"""Get videos for a camera with optional metadata"""
|
||||
try:
|
||||
videos = await self.video_repository.get_by_camera(
|
||||
camera_name=camera_name,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
if include_metadata:
|
||||
# Extract metadata for videos that don't have it
|
||||
await self._ensure_metadata_for_videos(videos)
|
||||
|
||||
return videos
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting videos for camera {camera_name}: {e}")
|
||||
return []
|
||||
|
||||
async def get_all_videos(
|
||||
self,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: Optional[int] = None,
|
||||
include_metadata: bool = False
|
||||
) -> List[VideoFile]:
|
||||
"""Get all videos with optional metadata"""
|
||||
try:
|
||||
videos = await self.video_repository.get_all(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
if include_metadata:
|
||||
await self._ensure_metadata_for_videos(videos)
|
||||
|
||||
return videos
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting all videos: {e}")
|
||||
return []
|
||||
|
||||
async def get_video_thumbnail(
|
||||
self,
|
||||
file_id: str,
|
||||
timestamp_seconds: float = 1.0,
|
||||
size: tuple = (320, 240)
|
||||
) -> Optional[bytes]:
|
||||
"""Get thumbnail for video"""
|
||||
try:
|
||||
video_file = await self.video_repository.get_by_id(file_id)
|
||||
if not video_file or not video_file.is_streamable:
|
||||
return None
|
||||
|
||||
return await self.metadata_extractor.extract_thumbnail(
|
||||
video_file.file_path,
|
||||
timestamp_seconds=timestamp_seconds,
|
||||
size=size
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting thumbnail for {file_id}: {e}")
|
||||
return None
|
||||
|
||||
async def prepare_for_streaming(self, file_id: str) -> Optional[VideoFile]:
|
||||
"""Prepare video for web streaming (convert if needed)"""
|
||||
try:
|
||||
video_file = await self.video_repository.get_by_id(file_id)
|
||||
if not video_file:
|
||||
return None
|
||||
|
||||
# Ensure metadata is available
|
||||
await self._ensure_metadata(video_file)
|
||||
|
||||
# Check if conversion is needed for web compatibility
|
||||
if video_file.needs_conversion():
|
||||
converted_file = await self._convert_for_web(video_file)
|
||||
return converted_file if converted_file else video_file
|
||||
|
||||
return video_file
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error preparing video {file_id} for streaming: {e}")
|
||||
return None
|
||||
|
||||
async def validate_video(self, file_id: str) -> bool:
|
||||
"""Validate that video file is accessible and valid"""
|
||||
try:
|
||||
video_file = await self.video_repository.get_by_id(file_id)
|
||||
if not video_file:
|
||||
return False
|
||||
|
||||
# Check file exists and is readable
|
||||
if not video_file.file_path.exists():
|
||||
return False
|
||||
|
||||
# Validate video format
|
||||
return await self.metadata_extractor.is_valid_video(video_file.file_path)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating video {file_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _ensure_metadata(self, video_file: VideoFile) -> None:
|
||||
"""Ensure video has metadata extracted"""
|
||||
if video_file.metadata:
|
||||
return
|
||||
|
||||
try:
|
||||
metadata = await self.metadata_extractor.extract(video_file.file_path)
|
||||
if metadata:
|
||||
# Update video file with metadata
|
||||
# Note: In a real implementation, you might want to persist this
|
||||
video_file.metadata = metadata
|
||||
self.logger.debug(f"Extracted metadata for {video_file.file_id}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not extract metadata for {video_file.file_id}: {e}")
|
||||
|
||||
async def _ensure_metadata_for_videos(self, videos: List[VideoFile]) -> None:
|
||||
"""Extract metadata for multiple videos concurrently"""
|
||||
tasks = []
|
||||
for video in videos:
|
||||
if not video.metadata:
|
||||
tasks.append(self._ensure_metadata(video))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def _convert_for_web(self, video_file: VideoFile) -> Optional[VideoFile]:
|
||||
"""Convert video to web-compatible format"""
|
||||
try:
|
||||
target_format = video_file.web_compatible_format
|
||||
|
||||
# Get path for converted file
|
||||
converted_path = await self.video_converter.get_converted_path(
|
||||
video_file.file_path,
|
||||
target_format
|
||||
)
|
||||
|
||||
# Perform conversion
|
||||
success = await self.video_converter.convert(
|
||||
source_path=video_file.file_path,
|
||||
target_path=converted_path,
|
||||
target_format=target_format,
|
||||
quality="medium"
|
||||
)
|
||||
|
||||
if success and converted_path.exists():
|
||||
# Create new VideoFile object for converted file
|
||||
converted_video = VideoFile(
|
||||
file_id=f"{video_file.file_id}_converted",
|
||||
camera_name=video_file.camera_name,
|
||||
filename=converted_path.name,
|
||||
file_path=converted_path,
|
||||
file_size_bytes=converted_path.stat().st_size,
|
||||
created_at=video_file.created_at,
|
||||
status=video_file.status,
|
||||
format=target_format,
|
||||
metadata=video_file.metadata,
|
||||
start_time=video_file.start_time,
|
||||
end_time=video_file.end_time,
|
||||
machine_trigger=video_file.machine_trigger
|
||||
)
|
||||
|
||||
self.logger.info(f"Successfully converted {video_file.file_id} to {target_format.value}")
|
||||
return converted_video
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error converting video {video_file.file_id}: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Video Domain Layer.
|
||||
|
||||
Contains pure business logic and domain models for video operations.
|
||||
No external dependencies - only Python standard library and domain concepts.
|
||||
"""
|
||||
|
||||
from .models import VideoFile, VideoMetadata, StreamRange
|
||||
from .interfaces import VideoRepository, VideoConverter, MetadataExtractor
|
||||
|
||||
__all__ = [
|
||||
"VideoFile",
|
||||
"VideoMetadata",
|
||||
"StreamRange",
|
||||
"VideoRepository",
|
||||
"VideoConverter",
|
||||
"MetadataExtractor",
|
||||
]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Video Domain Interfaces.
|
||||
|
||||
Abstract interfaces that define contracts for video operations.
|
||||
These interfaces allow dependency inversion - domain logic doesn't depend on infrastructure.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, BinaryIO
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .models import VideoFile, VideoMetadata, StreamRange, VideoFormat
|
||||
|
||||
|
||||
class VideoRepository(ABC):
|
||||
"""Abstract repository for video file access"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self, file_id: str) -> Optional[VideoFile]:
|
||||
"""Get video file by ID"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_camera(
|
||||
self,
|
||||
camera_name: str,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: Optional[int] = None
|
||||
) -> List[VideoFile]:
|
||||
"""Get video files for a camera with optional filters"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_all(
|
||||
self,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: Optional[int] = None
|
||||
) -> List[VideoFile]:
|
||||
"""Get all video files with optional filters"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, file_id: str) -> bool:
|
||||
"""Check if video file exists"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_file_stream(self, video_file: VideoFile) -> BinaryIO:
|
||||
"""Get file stream for reading video data"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_file_range(
|
||||
self,
|
||||
video_file: VideoFile,
|
||||
range_request: StreamRange
|
||||
) -> bytes:
|
||||
"""Get specific byte range from video file"""
|
||||
pass
|
||||
|
||||
|
||||
class VideoConverter(ABC):
|
||||
"""Abstract video format converter"""
|
||||
|
||||
@abstractmethod
|
||||
async def convert(
|
||||
self,
|
||||
source_path: Path,
|
||||
target_path: Path,
|
||||
target_format: VideoFormat,
|
||||
quality: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Convert video to target format"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def is_conversion_needed(
|
||||
self,
|
||||
source_format: VideoFormat,
|
||||
target_format: VideoFormat
|
||||
) -> bool:
|
||||
"""Check if conversion is needed"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_converted_path(
|
||||
self,
|
||||
original_path: Path,
|
||||
target_format: VideoFormat
|
||||
) -> Path:
|
||||
"""Get path for converted file"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def cleanup_converted_files(self, max_age_hours: int = 24) -> int:
|
||||
"""Clean up old converted files"""
|
||||
pass
|
||||
|
||||
|
||||
class MetadataExtractor(ABC):
|
||||
"""Abstract video metadata extractor"""
|
||||
|
||||
@abstractmethod
|
||||
async def extract(self, file_path: Path) -> Optional[VideoMetadata]:
|
||||
"""Extract metadata from video file"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def extract_thumbnail(
|
||||
self,
|
||||
file_path: Path,
|
||||
timestamp_seconds: float = 1.0,
|
||||
size: tuple = (320, 240)
|
||||
) -> Optional[bytes]:
|
||||
"""Extract thumbnail image from video"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def is_valid_video(self, file_path: Path) -> bool:
|
||||
"""Check if file is a valid video"""
|
||||
pass
|
||||
|
||||
|
||||
class StreamingCache(ABC):
|
||||
"""Abstract cache for streaming optimization"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_cached_range(
|
||||
self,
|
||||
file_id: str,
|
||||
range_request: StreamRange
|
||||
) -> Optional[bytes]:
|
||||
"""Get cached byte range"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def cache_range(
|
||||
self,
|
||||
file_id: str,
|
||||
range_request: StreamRange,
|
||||
data: bytes
|
||||
) -> None:
|
||||
"""Cache byte range data"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def invalidate_file(self, file_id: str) -> None:
|
||||
"""Invalidate all cached data for a file"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def cleanup_cache(self, max_size_mb: int = 100) -> int:
|
||||
"""Clean up cache to stay under size limit"""
|
||||
pass
|
||||
162
camera-management-api/usda_vision_system/video/domain/models.py
Normal file
162
camera-management-api/usda_vision_system/video/domain/models.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Video Domain Models.
|
||||
|
||||
Pure business entities and value objects for video operations.
|
||||
These models contain no external dependencies and represent core business concepts.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class VideoFormat(Enum):
|
||||
"""Supported video formats"""
|
||||
AVI = "avi"
|
||||
MP4 = "mp4"
|
||||
WEBM = "webm"
|
||||
|
||||
|
||||
class VideoStatus(Enum):
|
||||
"""Video file status"""
|
||||
RECORDING = "recording"
|
||||
COMPLETED = "completed"
|
||||
PROCESSING = "processing"
|
||||
ERROR = "error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoMetadata:
|
||||
"""Video metadata value object"""
|
||||
duration_seconds: float
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
codec: str
|
||||
bitrate: Optional[int] = None
|
||||
|
||||
@property
|
||||
def resolution(self) -> Tuple[int, int]:
|
||||
"""Get video resolution as tuple"""
|
||||
return (self.width, self.height)
|
||||
|
||||
@property
|
||||
def aspect_ratio(self) -> float:
|
||||
"""Calculate aspect ratio"""
|
||||
return self.width / self.height if self.height > 0 else 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamRange:
|
||||
"""HTTP range request value object"""
|
||||
start: int
|
||||
end: Optional[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.start < 0:
|
||||
raise ValueError("Start byte cannot be negative")
|
||||
if self.end is not None and self.end < self.start:
|
||||
raise ValueError("End byte cannot be less than start byte")
|
||||
|
||||
@property
|
||||
def size(self) -> Optional[int]:
|
||||
"""Get range size in bytes"""
|
||||
if self.end is not None:
|
||||
return self.end - self.start + 1
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_header(cls, range_header: str, file_size: int) -> 'StreamRange':
|
||||
"""Parse HTTP Range header"""
|
||||
if not range_header.startswith('bytes='):
|
||||
raise ValueError("Invalid range header format")
|
||||
|
||||
range_spec = range_header[6:] # Remove 'bytes='
|
||||
|
||||
if '-' not in range_spec:
|
||||
raise ValueError("Invalid range specification")
|
||||
|
||||
start_str, end_str = range_spec.split('-', 1)
|
||||
|
||||
if start_str:
|
||||
start = int(start_str)
|
||||
else:
|
||||
# Suffix range (e.g., "-500" means last 500 bytes)
|
||||
if not end_str:
|
||||
raise ValueError("Invalid range specification")
|
||||
suffix_length = int(end_str)
|
||||
start = max(0, file_size - suffix_length)
|
||||
end = file_size - 1
|
||||
return cls(start=start, end=end)
|
||||
|
||||
if end_str:
|
||||
end = min(int(end_str), file_size - 1)
|
||||
else:
|
||||
end = file_size - 1
|
||||
|
||||
return cls(start=start, end=end)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoFile:
|
||||
"""Video file entity"""
|
||||
file_id: str
|
||||
camera_name: str
|
||||
filename: str
|
||||
file_path: Path
|
||||
file_size_bytes: int
|
||||
created_at: datetime
|
||||
status: VideoStatus
|
||||
format: VideoFormat
|
||||
metadata: Optional[VideoMetadata] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
machine_trigger: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate video file data"""
|
||||
if not self.file_id:
|
||||
raise ValueError("File ID cannot be empty")
|
||||
if not self.camera_name:
|
||||
raise ValueError("Camera name cannot be empty")
|
||||
if self.file_size_bytes < 0:
|
||||
raise ValueError("File size cannot be negative")
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> Optional[float]:
|
||||
"""Get video duration from metadata"""
|
||||
return self.metadata.duration_seconds if self.metadata else None
|
||||
|
||||
@property
|
||||
def is_streamable(self) -> bool:
|
||||
"""Check if video can be streamed"""
|
||||
return (
|
||||
self.status in [VideoStatus.COMPLETED, VideoStatus.RECORDING] and
|
||||
self.file_path.exists() and
|
||||
self.file_size_bytes > 0
|
||||
)
|
||||
|
||||
@property
|
||||
def web_compatible_format(self) -> VideoFormat:
|
||||
"""Get web-compatible format for this video"""
|
||||
# AVI files should be converted to MP4 for web compatibility
|
||||
if self.format == VideoFormat.AVI:
|
||||
return VideoFormat.MP4
|
||||
return self.format
|
||||
|
||||
def needs_conversion(self) -> bool:
|
||||
"""Check if video needs format conversion for web streaming"""
|
||||
return self.format != self.web_compatible_format
|
||||
|
||||
def get_converted_filename(self) -> str:
|
||||
"""Get filename for converted version"""
|
||||
if not self.needs_conversion():
|
||||
return self.filename
|
||||
|
||||
# Replace extension with web-compatible format
|
||||
stem = Path(self.filename).stem
|
||||
return f"{stem}.{self.web_compatible_format.value}"
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Video Infrastructure Layer.
|
||||
|
||||
Contains implementations of domain interfaces using external dependencies
|
||||
like file systems, FFmpeg, OpenCV, etc.
|
||||
"""
|
||||
|
||||
from .repositories import FileSystemVideoRepository
|
||||
from .converters import FFmpegVideoConverter
|
||||
from .metadata_extractors import OpenCVMetadataExtractor
|
||||
from .caching import InMemoryStreamingCache
|
||||
|
||||
__all__ = [
|
||||
"FileSystemVideoRepository",
|
||||
"FFmpegVideoConverter",
|
||||
"OpenCVMetadataExtractor",
|
||||
"InMemoryStreamingCache",
|
||||
]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Streaming Cache Implementations.
|
||||
|
||||
In-memory and file-based caching for video streaming optimization.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Dict, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
|
||||
from ..domain.interfaces import StreamingCache
|
||||
from ..domain.models import StreamRange
|
||||
|
||||
|
||||
class InMemoryStreamingCache(StreamingCache):
|
||||
"""In-memory cache for video streaming"""
|
||||
|
||||
def __init__(self, max_size_mb: int = 100, max_age_minutes: int = 30):
|
||||
self.max_size_bytes = max_size_mb * 1024 * 1024
|
||||
self.max_age = timedelta(minutes=max_age_minutes)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache storage: {cache_key: (data, timestamp, size)}
|
||||
self._cache: Dict[str, Tuple[bytes, datetime, int]] = {}
|
||||
self._current_size = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_cached_range(
|
||||
self,
|
||||
file_id: str,
|
||||
range_request: StreamRange
|
||||
) -> Optional[bytes]:
|
||||
"""Get cached byte range"""
|
||||
cache_key = self._generate_cache_key(file_id, range_request)
|
||||
|
||||
async with self._lock:
|
||||
if cache_key in self._cache:
|
||||
data, timestamp, size = self._cache[cache_key]
|
||||
|
||||
# Check if cache entry is still valid
|
||||
if datetime.now() - timestamp <= self.max_age:
|
||||
self.logger.debug(f"Cache hit for {file_id} range {range_request.start}-{range_request.end}")
|
||||
return data
|
||||
else:
|
||||
# Remove expired entry
|
||||
del self._cache[cache_key]
|
||||
self._current_size -= size
|
||||
self.logger.debug(f"Cache entry expired for {file_id}")
|
||||
|
||||
return None
|
||||
|
||||
async def cache_range(
|
||||
self,
|
||||
file_id: str,
|
||||
range_request: StreamRange,
|
||||
data: bytes
|
||||
) -> None:
|
||||
"""Cache byte range data"""
|
||||
cache_key = self._generate_cache_key(file_id, range_request)
|
||||
data_size = len(data)
|
||||
|
||||
async with self._lock:
|
||||
# Check if we need to make space
|
||||
while self._current_size + data_size > self.max_size_bytes and self._cache:
|
||||
await self._evict_oldest()
|
||||
|
||||
# Add to cache
|
||||
self._cache[cache_key] = (data, datetime.now(), data_size)
|
||||
self._current_size += data_size
|
||||
|
||||
self.logger.debug(f"Cached {data_size} bytes for {file_id} range {range_request.start}-{range_request.end}")
|
||||
|
||||
async def invalidate_file(self, file_id: str) -> None:
|
||||
"""Invalidate all cached data for a file"""
|
||||
async with self._lock:
|
||||
keys_to_remove = [key for key in self._cache.keys() if key.startswith(f"{file_id}:")]
|
||||
|
||||
for key in keys_to_remove:
|
||||
_, _, size = self._cache[key]
|
||||
del self._cache[key]
|
||||
self._current_size -= size
|
||||
|
||||
if keys_to_remove:
|
||||
self.logger.info(f"Invalidated {len(keys_to_remove)} cache entries for {file_id}")
|
||||
|
||||
async def cleanup_cache(self, max_size_mb: int = 100) -> int:
|
||||
"""Clean up cache to stay under size limit"""
|
||||
target_size = max_size_mb * 1024 * 1024
|
||||
entries_removed = 0
|
||||
|
||||
async with self._lock:
|
||||
# Remove expired entries first
|
||||
current_time = datetime.now()
|
||||
expired_keys = [
|
||||
key for key, (_, timestamp, _) in self._cache.items()
|
||||
if current_time - timestamp > self.max_age
|
||||
]
|
||||
|
||||
for key in expired_keys:
|
||||
_, _, size = self._cache[key]
|
||||
del self._cache[key]
|
||||
self._current_size -= size
|
||||
entries_removed += 1
|
||||
|
||||
# Remove oldest entries if still over limit
|
||||
while self._current_size > target_size and self._cache:
|
||||
await self._evict_oldest()
|
||||
entries_removed += 1
|
||||
|
||||
if entries_removed > 0:
|
||||
self.logger.info(f"Cache cleanup removed {entries_removed} entries")
|
||||
|
||||
return entries_removed
|
||||
|
||||
async def _evict_oldest(self) -> None:
|
||||
"""Evict the oldest cache entry"""
|
||||
if not self._cache:
|
||||
return
|
||||
|
||||
# Find oldest entry
|
||||
oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k][1])
|
||||
_, _, size = self._cache[oldest_key]
|
||||
del self._cache[oldest_key]
|
||||
self._current_size -= size
|
||||
|
||||
self.logger.debug(f"Evicted cache entry: {oldest_key}")
|
||||
|
||||
def _generate_cache_key(self, file_id: str, range_request: StreamRange) -> str:
|
||||
"""Generate cache key for file and range"""
|
||||
range_str = f"{range_request.start}-{range_request.end}"
|
||||
return f"{file_id}:{range_str}"
|
||||
|
||||
async def get_cache_stats(self) -> dict:
|
||||
"""Get cache statistics"""
|
||||
async with self._lock:
|
||||
return {
|
||||
"entries": len(self._cache),
|
||||
"size_bytes": self._current_size,
|
||||
"size_mb": self._current_size / (1024 * 1024),
|
||||
"max_size_mb": self.max_size_bytes / (1024 * 1024),
|
||||
"utilization_percent": (self._current_size / self.max_size_bytes) * 100
|
||||
}
|
||||
|
||||
|
||||
class NoOpStreamingCache(StreamingCache):
|
||||
"""No-operation cache that doesn't actually cache anything"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_cached_range(
|
||||
self,
|
||||
file_id: str,
|
||||
range_request: StreamRange
|
||||
) -> Optional[bytes]:
|
||||
"""Always return None (no cache)"""
|
||||
return None
|
||||
|
||||
async def cache_range(
|
||||
self,
|
||||
file_id: str,
|
||||
range_request: StreamRange,
|
||||
data: bytes
|
||||
) -> None:
|
||||
"""No-op caching"""
|
||||
pass
|
||||
|
||||
async def invalidate_file(self, file_id: str) -> None:
|
||||
"""No-op invalidation"""
|
||||
pass
|
||||
|
||||
async def cleanup_cache(self, max_size_mb: int = 100) -> int:
|
||||
"""No-op cleanup"""
|
||||
return 0
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Video Format Converters.
|
||||
|
||||
Implementations for converting video formats using FFmpeg.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..domain.interfaces import VideoConverter
|
||||
from ..domain.models import VideoFormat
|
||||
|
||||
|
||||
class FFmpegVideoConverter(VideoConverter):
|
||||
"""FFmpeg-based video converter"""
|
||||
|
||||
def __init__(self, temp_dir: Optional[Path] = None):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.temp_dir = temp_dir or Path("/tmp/video_conversions")
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if FFmpeg is available
|
||||
self._ffmpeg_available = shutil.which("ffmpeg") is not None
|
||||
if not self._ffmpeg_available:
|
||||
self.logger.warning("FFmpeg not found - video conversion will be disabled")
|
||||
|
||||
async def convert(
|
||||
self,
|
||||
source_path: Path,
|
||||
target_path: Path,
|
||||
target_format: VideoFormat,
|
||||
quality: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Convert video to target format"""
|
||||
if not self._ffmpeg_available:
|
||||
self.logger.error("FFmpeg not available for conversion")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Ensure target directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build FFmpeg command
|
||||
cmd = self._build_ffmpeg_command(source_path, target_path, target_format, quality)
|
||||
|
||||
self.logger.info(f"Converting {source_path} to {target_path} using FFmpeg")
|
||||
|
||||
# Run FFmpeg conversion
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
self.logger.info(f"Successfully converted {source_path} to {target_path}")
|
||||
return True
|
||||
else:
|
||||
error_msg = stderr.decode() if stderr else "Unknown FFmpeg error"
|
||||
self.logger.error(f"FFmpeg conversion failed: {error_msg}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during video conversion: {e}")
|
||||
return False
|
||||
|
||||
async def is_conversion_needed(
|
||||
self,
|
||||
source_format: VideoFormat,
|
||||
target_format: VideoFormat
|
||||
) -> bool:
|
||||
"""Check if conversion is needed"""
|
||||
return source_format != target_format
|
||||
|
||||
async def get_converted_path(
|
||||
self,
|
||||
original_path: Path,
|
||||
target_format: VideoFormat
|
||||
) -> Path:
|
||||
"""Get path for converted file"""
|
||||
# Place converted files in temp directory with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
stem = original_path.stem
|
||||
converted_filename = f"{stem}_{timestamp}.{target_format.value}"
|
||||
return self.temp_dir / converted_filename
|
||||
|
||||
async def cleanup_converted_files(self, max_age_hours: int = 24) -> int:
|
||||
"""Clean up old converted files"""
|
||||
try:
|
||||
cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
|
||||
files_removed = 0
|
||||
|
||||
if not self.temp_dir.exists():
|
||||
return 0
|
||||
|
||||
for file_path in self.temp_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
# Get file modification time
|
||||
file_mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
|
||||
|
||||
if file_mtime < cutoff_time:
|
||||
try:
|
||||
file_path.unlink()
|
||||
files_removed += 1
|
||||
self.logger.debug(f"Removed old converted file: {file_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not remove {file_path}: {e}")
|
||||
|
||||
self.logger.info(f"Cleaned up {files_removed} old converted files")
|
||||
return files_removed
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during converted files cleanup: {e}")
|
||||
return 0
|
||||
|
||||
def _build_ffmpeg_command(
|
||||
self,
|
||||
source_path: Path,
|
||||
target_path: Path,
|
||||
target_format: VideoFormat,
|
||||
quality: Optional[str] = None
|
||||
) -> list:
|
||||
"""Build FFmpeg command for conversion"""
|
||||
cmd = ["ffmpeg", "-i", str(source_path)]
|
||||
|
||||
# Add format-specific options
|
||||
if target_format == VideoFormat.MP4:
|
||||
cmd.extend([
|
||||
"-c:v", "libx264", # H.264 video codec
|
||||
"-c:a", "aac", # AAC audio codec
|
||||
"-movflags", "+faststart", # Enable progressive download
|
||||
])
|
||||
|
||||
# Quality settings
|
||||
if quality == "high":
|
||||
cmd.extend(["-crf", "18"])
|
||||
elif quality == "medium":
|
||||
cmd.extend(["-crf", "23"])
|
||||
elif quality == "low":
|
||||
cmd.extend(["-crf", "28"])
|
||||
else:
|
||||
cmd.extend(["-crf", "23"]) # Default medium quality
|
||||
|
||||
elif target_format == VideoFormat.WEBM:
|
||||
cmd.extend([
|
||||
"-c:v", "libvpx-vp9", # VP9 video codec
|
||||
"-c:a", "libopus", # Opus audio codec
|
||||
])
|
||||
|
||||
# Quality settings for WebM
|
||||
if quality == "high":
|
||||
cmd.extend(["-crf", "15", "-b:v", "0"])
|
||||
elif quality == "medium":
|
||||
cmd.extend(["-crf", "20", "-b:v", "0"])
|
||||
elif quality == "low":
|
||||
cmd.extend(["-crf", "25", "-b:v", "0"])
|
||||
else:
|
||||
cmd.extend(["-crf", "20", "-b:v", "0"]) # Default medium quality
|
||||
|
||||
# Common options
|
||||
cmd.extend([
|
||||
"-preset", "fast", # Encoding speed vs compression trade-off
|
||||
"-y", # Overwrite output file
|
||||
str(target_path)
|
||||
])
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
class NoOpVideoConverter(VideoConverter):
|
||||
"""No-operation converter for when FFmpeg is not available"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def convert(
|
||||
self,
|
||||
source_path: Path,
|
||||
target_path: Path,
|
||||
target_format: VideoFormat,
|
||||
quality: Optional[str] = None
|
||||
) -> bool:
|
||||
"""No-op conversion - just copy file if formats match"""
|
||||
try:
|
||||
if source_path.suffix.lower().lstrip('.') == target_format.value:
|
||||
# Same format, just copy
|
||||
shutil.copy2(source_path, target_path)
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"Cannot convert {source_path} to {target_format} - no converter available")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in no-op conversion: {e}")
|
||||
return False
|
||||
|
||||
async def is_conversion_needed(
|
||||
self,
|
||||
source_format: VideoFormat,
|
||||
target_format: VideoFormat
|
||||
) -> bool:
|
||||
"""Check if conversion is needed"""
|
||||
return source_format != target_format
|
||||
|
||||
async def get_converted_path(
|
||||
self,
|
||||
original_path: Path,
|
||||
target_format: VideoFormat
|
||||
) -> Path:
|
||||
"""Get path for converted file"""
|
||||
return original_path.with_suffix(f".{target_format.value}")
|
||||
|
||||
async def cleanup_converted_files(self, max_age_hours: int = 24) -> int:
|
||||
"""No-op cleanup"""
|
||||
return 0
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Video Metadata Extractors.
|
||||
|
||||
Implementations for extracting video metadata using OpenCV and other tools.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from ..domain.interfaces import MetadataExtractor
|
||||
from ..domain.models import VideoMetadata
|
||||
|
||||
|
||||
class OpenCVMetadataExtractor(MetadataExtractor):
|
||||
"""OpenCV-based metadata extractor"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def extract(self, file_path: Path) -> Optional[VideoMetadata]:
|
||||
"""Extract metadata from video file using OpenCV"""
|
||||
try:
|
||||
# Run OpenCV operations in thread pool to avoid blocking
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self._extract_sync, file_path
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting metadata from {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def _extract_sync(self, file_path: Path) -> Optional[VideoMetadata]:
|
||||
"""Synchronous metadata extraction"""
|
||||
cap = None
|
||||
try:
|
||||
cap = cv2.VideoCapture(str(file_path))
|
||||
|
||||
if not cap.isOpened():
|
||||
self.logger.warning(f"Could not open video file: {file_path}")
|
||||
return None
|
||||
|
||||
# Get video properties
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
# Calculate duration
|
||||
duration_seconds = frame_count / fps if fps > 0 else 0.0
|
||||
|
||||
# Get codec information
|
||||
fourcc = int(cap.get(cv2.CAP_PROP_FOURCC))
|
||||
codec = self._fourcc_to_string(fourcc)
|
||||
|
||||
# Try to get bitrate (not always available)
|
||||
bitrate = cap.get(cv2.CAP_PROP_BITRATE)
|
||||
bitrate = int(bitrate) if bitrate > 0 else None
|
||||
|
||||
return VideoMetadata(
|
||||
duration_seconds=duration_seconds,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
codec=codec,
|
||||
bitrate=bitrate
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in sync metadata extraction: {e}")
|
||||
return None
|
||||
|
||||
finally:
|
||||
if cap is not None:
|
||||
cap.release()
|
||||
|
||||
async def extract_thumbnail(
|
||||
self,
|
||||
file_path: Path,
|
||||
timestamp_seconds: float = 1.0,
|
||||
size: tuple = (320, 240)
|
||||
) -> Optional[bytes]:
|
||||
"""Extract thumbnail image from video"""
|
||||
try:
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self._extract_thumbnail_sync, file_path, timestamp_seconds, size
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting thumbnail from {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def _extract_thumbnail_sync(
|
||||
self,
|
||||
file_path: Path,
|
||||
timestamp_seconds: float,
|
||||
size: tuple
|
||||
) -> Optional[bytes]:
|
||||
"""Synchronous thumbnail extraction"""
|
||||
cap = None
|
||||
try:
|
||||
cap = cv2.VideoCapture(str(file_path))
|
||||
|
||||
if not cap.isOpened():
|
||||
return None
|
||||
|
||||
# Get video FPS to calculate frame number
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
if fps <= 0:
|
||||
fps = 30 # Default fallback
|
||||
|
||||
# Calculate target frame
|
||||
target_frame = int(timestamp_seconds * fps)
|
||||
|
||||
# Set position to target frame
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
|
||||
|
||||
# Read frame
|
||||
ret, frame = cap.read()
|
||||
if not ret or frame is None:
|
||||
# Fallback to first frame
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
ret, frame = cap.read()
|
||||
if not ret or frame is None:
|
||||
return None
|
||||
|
||||
# Resize frame to thumbnail size
|
||||
thumbnail = cv2.resize(frame, size)
|
||||
|
||||
# Encode as JPEG
|
||||
success, buffer = cv2.imencode('.jpg', thumbnail, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if success:
|
||||
return buffer.tobytes()
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in sync thumbnail extraction: {e}")
|
||||
return None
|
||||
|
||||
finally:
|
||||
if cap is not None:
|
||||
cap.release()
|
||||
|
||||
async def is_valid_video(self, file_path: Path) -> bool:
|
||||
"""Check if file is a valid video"""
|
||||
try:
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, self._is_valid_video_sync, file_path
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating video {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def _is_valid_video_sync(self, file_path: Path) -> bool:
|
||||
"""Synchronous video validation"""
|
||||
cap = None
|
||||
try:
|
||||
if not file_path.exists():
|
||||
return False
|
||||
|
||||
cap = cv2.VideoCapture(str(file_path))
|
||||
|
||||
if not cap.isOpened():
|
||||
return False
|
||||
|
||||
# Try to read first frame
|
||||
ret, frame = cap.read()
|
||||
return ret and frame is not None
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
finally:
|
||||
if cap is not None:
|
||||
cap.release()
|
||||
|
||||
def _fourcc_to_string(self, fourcc: int) -> str:
|
||||
"""Convert OpenCV fourcc code to string"""
|
||||
try:
|
||||
# Convert fourcc integer to 4-character string
|
||||
fourcc_bytes = [
|
||||
(fourcc & 0xFF),
|
||||
((fourcc >> 8) & 0xFF),
|
||||
((fourcc >> 16) & 0xFF),
|
||||
((fourcc >> 24) & 0xFF)
|
||||
]
|
||||
|
||||
# Convert to string, handling non-printable characters
|
||||
codec_chars = []
|
||||
for byte_val in fourcc_bytes:
|
||||
if 32 <= byte_val <= 126: # Printable ASCII
|
||||
codec_chars.append(chr(byte_val))
|
||||
else:
|
||||
codec_chars.append('?')
|
||||
|
||||
return ''.join(codec_chars).strip()
|
||||
|
||||
except Exception:
|
||||
return "UNKNOWN"
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Video Repository Implementations.
|
||||
|
||||
File system-based implementation of video repository interface.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Optional, BinaryIO
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import aiofiles
|
||||
|
||||
from ..domain.interfaces import VideoRepository
|
||||
from ..domain.models import VideoFile, VideoFormat, VideoStatus, StreamRange
|
||||
from ...core.config import Config
|
||||
from ...storage.manager import StorageManager
|
||||
|
||||
|
||||
class FileSystemVideoRepository(VideoRepository):
|
||||
"""File system implementation of video repository"""
|
||||
|
||||
def __init__(self, config: Config, storage_manager: StorageManager):
|
||||
self.config = config
|
||||
self.storage_manager = storage_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_by_id(self, file_id: str) -> Optional[VideoFile]:
|
||||
"""Get video file by ID"""
|
||||
try:
|
||||
# Get file info from storage manager
|
||||
file_info = self.storage_manager.get_file_info(file_id)
|
||||
if not file_info:
|
||||
return None
|
||||
|
||||
return self._convert_to_video_file(file_info)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting video by ID {file_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_by_camera(
|
||||
self,
|
||||
camera_name: str,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: Optional[int] = None
|
||||
) -> List[VideoFile]:
|
||||
"""Get video files for a camera with optional filters"""
|
||||
try:
|
||||
# Use storage manager to get files
|
||||
files = self.storage_manager.get_recording_files(
|
||||
camera_name=camera_name,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return [self._convert_to_video_file(file_info) for file_info in files]
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting videos for camera {camera_name}: {e}")
|
||||
return []
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
limit: Optional[int] = None
|
||||
) -> List[VideoFile]:
|
||||
"""Get all video files with optional filters"""
|
||||
try:
|
||||
# Get files from all cameras
|
||||
files = self.storage_manager.get_recording_files(
|
||||
camera_name=None, # All cameras
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return [self._convert_to_video_file(file_info) for file_info in files]
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting all videos: {e}")
|
||||
return []
|
||||
|
||||
async def exists(self, file_id: str) -> bool:
|
||||
"""Check if video file exists"""
|
||||
try:
|
||||
video_file = await self.get_by_id(file_id)
|
||||
return video_file is not None and video_file.file_path.exists()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking if video exists {file_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_file_stream(self, video_file: VideoFile) -> BinaryIO:
|
||||
"""Get file stream for reading video data"""
|
||||
try:
|
||||
# Use aiofiles for async file operations
|
||||
return await aiofiles.open(video_file.file_path, 'rb')
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error opening file stream for {video_file.file_id}: {e}")
|
||||
raise
|
||||
|
||||
async def get_file_range(
|
||||
self,
|
||||
video_file: VideoFile,
|
||||
range_request: StreamRange
|
||||
) -> bytes:
|
||||
"""Get specific byte range from video file"""
|
||||
try:
|
||||
async with aiofiles.open(video_file.file_path, 'rb') as f:
|
||||
# Seek to start position
|
||||
await f.seek(range_request.start)
|
||||
|
||||
# Calculate how many bytes to read
|
||||
if range_request.end is not None:
|
||||
bytes_to_read = range_request.end - range_request.start + 1
|
||||
data = await f.read(bytes_to_read)
|
||||
else:
|
||||
# Read to end of file
|
||||
data = await f.read()
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading file range for {video_file.file_id}: {e}")
|
||||
raise
|
||||
|
||||
def _convert_to_video_file(self, file_info: dict) -> VideoFile:
|
||||
"""Convert storage manager file info to VideoFile domain model"""
|
||||
try:
|
||||
file_path = Path(file_info["filename"])
|
||||
|
||||
# Determine video format from extension
|
||||
extension = file_path.suffix.lower().lstrip('.')
|
||||
if extension == 'avi':
|
||||
format = VideoFormat.AVI
|
||||
elif extension == 'mp4':
|
||||
format = VideoFormat.MP4
|
||||
elif extension == 'webm':
|
||||
format = VideoFormat.WEBM
|
||||
else:
|
||||
format = VideoFormat.AVI # Default fallback
|
||||
|
||||
# Parse status
|
||||
status_str = file_info.get("status", "unknown")
|
||||
try:
|
||||
status = VideoStatus(status_str)
|
||||
except ValueError:
|
||||
status = VideoStatus.UNKNOWN
|
||||
|
||||
# Parse timestamps
|
||||
start_time = None
|
||||
if file_info.get("start_time"):
|
||||
start_time = datetime.fromisoformat(file_info["start_time"])
|
||||
|
||||
end_time = None
|
||||
if file_info.get("end_time"):
|
||||
end_time = datetime.fromisoformat(file_info["end_time"])
|
||||
|
||||
created_at = start_time or datetime.now()
|
||||
|
||||
return VideoFile(
|
||||
file_id=file_info["file_id"],
|
||||
camera_name=file_info["camera_name"],
|
||||
filename=file_info["filename"],
|
||||
file_path=file_path,
|
||||
file_size_bytes=file_info.get("file_size_bytes", 0),
|
||||
created_at=created_at,
|
||||
status=status,
|
||||
format=format,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
machine_trigger=file_info.get("machine_trigger"),
|
||||
error_message=None # Could be added to storage manager later
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error converting file info to VideoFile: {e}")
|
||||
raise
|
||||
197
camera-management-api/usda_vision_system/video/integration.py
Normal file
197
camera-management-api/usda_vision_system/video/integration.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Video Module Integration.
|
||||
|
||||
Integrates the modular video system with the existing USDA Vision Camera System.
|
||||
This module handles dependency injection and service composition.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from ..core.config import Config
|
||||
from ..storage.manager import StorageManager
|
||||
|
||||
# Domain interfaces
|
||||
from .domain.interfaces import VideoRepository, VideoConverter, MetadataExtractor, StreamingCache
|
||||
|
||||
# Infrastructure implementations
|
||||
from .infrastructure.repositories import FileSystemVideoRepository
|
||||
from .infrastructure.converters import FFmpegVideoConverter, NoOpVideoConverter
|
||||
from .infrastructure.metadata_extractors import OpenCVMetadataExtractor
|
||||
from .infrastructure.caching import InMemoryStreamingCache, NoOpStreamingCache
|
||||
|
||||
# Application services
|
||||
from .application.video_service import VideoService
|
||||
from .application.streaming_service import StreamingService
|
||||
|
||||
# Presentation layer
|
||||
from .presentation.controllers import VideoController, StreamingController
|
||||
from .presentation.routes import create_video_routes, create_admin_video_routes
|
||||
|
||||
|
||||
class VideoModuleConfig:
|
||||
"""Configuration for video module"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enable_caching: bool = True,
|
||||
cache_size_mb: int = 100,
|
||||
cache_max_age_minutes: int = 30,
|
||||
enable_conversion: bool = True,
|
||||
conversion_quality: str = "medium"
|
||||
):
|
||||
self.enable_caching = enable_caching
|
||||
self.cache_size_mb = cache_size_mb
|
||||
self.cache_max_age_minutes = cache_max_age_minutes
|
||||
self.enable_conversion = enable_conversion
|
||||
self.conversion_quality = conversion_quality
|
||||
|
||||
|
||||
class VideoModule:
|
||||
"""
|
||||
Main video module that provides dependency injection and service composition.
|
||||
|
||||
This class follows the composition root pattern, creating and wiring up
|
||||
all dependencies for the video streaming functionality.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
storage_manager: StorageManager,
|
||||
video_config: Optional[VideoModuleConfig] = None
|
||||
):
|
||||
self.config = config
|
||||
self.storage_manager = storage_manager
|
||||
self.video_config = video_config or VideoModuleConfig()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize services
|
||||
self._initialize_services()
|
||||
|
||||
self.logger.info("Video module initialized successfully")
|
||||
|
||||
def _initialize_services(self):
|
||||
"""Initialize all video services with proper dependency injection"""
|
||||
|
||||
# Infrastructure layer
|
||||
self.video_repository = self._create_video_repository()
|
||||
self.video_converter = self._create_video_converter()
|
||||
self.metadata_extractor = self._create_metadata_extractor()
|
||||
self.streaming_cache = self._create_streaming_cache()
|
||||
|
||||
# Application layer
|
||||
self.video_service = VideoService(
|
||||
video_repository=self.video_repository,
|
||||
metadata_extractor=self.metadata_extractor,
|
||||
video_converter=self.video_converter
|
||||
)
|
||||
|
||||
self.streaming_service = StreamingService(
|
||||
video_repository=self.video_repository,
|
||||
streaming_cache=self.streaming_cache
|
||||
)
|
||||
|
||||
# Presentation layer
|
||||
self.video_controller = VideoController(self.video_service)
|
||||
self.streaming_controller = StreamingController(
|
||||
streaming_service=self.streaming_service,
|
||||
video_service=self.video_service
|
||||
)
|
||||
|
||||
def _create_video_repository(self) -> VideoRepository:
|
||||
"""Create video repository implementation"""
|
||||
return FileSystemVideoRepository(
|
||||
config=self.config,
|
||||
storage_manager=self.storage_manager
|
||||
)
|
||||
|
||||
def _create_video_converter(self) -> VideoConverter:
|
||||
"""Create video converter implementation"""
|
||||
if self.video_config.enable_conversion:
|
||||
try:
|
||||
return FFmpegVideoConverter()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"FFmpeg converter not available, using no-op converter: {e}")
|
||||
return NoOpVideoConverter()
|
||||
else:
|
||||
return NoOpVideoConverter()
|
||||
|
||||
def _create_metadata_extractor(self) -> MetadataExtractor:
|
||||
"""Create metadata extractor implementation"""
|
||||
return OpenCVMetadataExtractor()
|
||||
|
||||
def _create_streaming_cache(self) -> StreamingCache:
|
||||
"""Create streaming cache implementation"""
|
||||
if self.video_config.enable_caching:
|
||||
return InMemoryStreamingCache(
|
||||
max_size_mb=self.video_config.cache_size_mb,
|
||||
max_age_minutes=self.video_config.cache_max_age_minutes
|
||||
)
|
||||
else:
|
||||
return NoOpStreamingCache()
|
||||
|
||||
def get_api_routes(self):
|
||||
"""Get FastAPI routes for video functionality"""
|
||||
return create_video_routes(
|
||||
video_controller=self.video_controller,
|
||||
streaming_controller=self.streaming_controller
|
||||
)
|
||||
|
||||
def get_admin_routes(self):
|
||||
"""Get admin routes for video management"""
|
||||
return create_admin_video_routes(
|
||||
streaming_controller=self.streaming_controller
|
||||
)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up video module resources"""
|
||||
try:
|
||||
# Clean up cache
|
||||
if self.streaming_cache:
|
||||
await self.streaming_cache.cleanup_cache()
|
||||
|
||||
# Clean up converted files
|
||||
if self.video_converter:
|
||||
await self.video_converter.cleanup_converted_files()
|
||||
|
||||
self.logger.info("Video module cleanup completed")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during video module cleanup: {e}")
|
||||
|
||||
def get_module_status(self) -> dict:
|
||||
"""Get status information about the video module"""
|
||||
return {
|
||||
"video_repository": type(self.video_repository).__name__,
|
||||
"video_converter": type(self.video_converter).__name__,
|
||||
"metadata_extractor": type(self.metadata_extractor).__name__,
|
||||
"streaming_cache": type(self.streaming_cache).__name__,
|
||||
"caching_enabled": self.video_config.enable_caching,
|
||||
"conversion_enabled": self.video_config.enable_conversion,
|
||||
"cache_size_mb": self.video_config.cache_size_mb
|
||||
}
|
||||
|
||||
|
||||
def create_video_module(
|
||||
config: Config,
|
||||
storage_manager: StorageManager,
|
||||
enable_caching: bool = True,
|
||||
enable_conversion: bool = True
|
||||
) -> VideoModule:
|
||||
"""
|
||||
Factory function to create a configured video module.
|
||||
|
||||
This is the main entry point for integrating video functionality
|
||||
into the existing USDA Vision Camera System.
|
||||
"""
|
||||
video_config = VideoModuleConfig(
|
||||
enable_caching=enable_caching,
|
||||
enable_conversion=enable_conversion
|
||||
)
|
||||
|
||||
return VideoModule(
|
||||
config=config,
|
||||
storage_manager=storage_manager,
|
||||
video_config=video_config
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Video Presentation Layer.
|
||||
|
||||
Contains HTTP controllers, request/response models, and API route definitions.
|
||||
"""
|
||||
|
||||
from .controllers import VideoController, StreamingController
|
||||
from .schemas import VideoInfoResponse, VideoListResponse, StreamingInfoResponse
|
||||
from .routes import create_video_routes
|
||||
|
||||
__all__ = [
|
||||
"VideoController",
|
||||
"StreamingController",
|
||||
"VideoInfoResponse",
|
||||
"VideoListResponse",
|
||||
"StreamingInfoResponse",
|
||||
"create_video_routes",
|
||||
]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Video HTTP Controllers.
|
||||
|
||||
Handle HTTP requests and responses for video operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..application.video_service import VideoService
|
||||
from ..application.streaming_service import StreamingService
|
||||
from ..domain.models import StreamRange, VideoFile
|
||||
from .schemas import VideoInfoResponse, VideoListResponse, VideoListRequest, StreamingInfoResponse, ThumbnailRequest, VideoMetadataResponse
|
||||
|
||||
|
||||
class VideoController:
|
||||
"""Controller for video management operations"""
|
||||
|
||||
def __init__(self, video_service: VideoService):
|
||||
self.video_service = video_service
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_video_info(self, file_id: str) -> VideoInfoResponse:
|
||||
"""Get video information"""
|
||||
video_file = await self.video_service.get_video_by_id(file_id)
|
||||
if not video_file:
|
||||
raise HTTPException(status_code=404, detail=f"Video {file_id} not found")
|
||||
|
||||
return self._convert_to_response(video_file)
|
||||
|
||||
async def list_videos(self, request: VideoListRequest) -> VideoListResponse:
|
||||
"""List videos with optional filters"""
|
||||
if request.camera_name:
|
||||
videos = await self.video_service.get_videos_by_camera(camera_name=request.camera_name, start_date=request.start_date, end_date=request.end_date, limit=request.limit, include_metadata=request.include_metadata)
|
||||
else:
|
||||
videos = await self.video_service.get_all_videos(start_date=request.start_date, end_date=request.end_date, limit=request.limit, include_metadata=request.include_metadata)
|
||||
|
||||
video_responses = [self._convert_to_response(video) for video in videos]
|
||||
|
||||
return VideoListResponse(videos=video_responses, total_count=len(video_responses))
|
||||
|
||||
async def get_video_thumbnail(self, file_id: str, thumbnail_request: ThumbnailRequest) -> Response:
|
||||
"""Get video thumbnail"""
|
||||
thumbnail_data = await self.video_service.get_video_thumbnail(file_id=file_id, timestamp_seconds=thumbnail_request.timestamp_seconds, size=(thumbnail_request.width, thumbnail_request.height))
|
||||
|
||||
if not thumbnail_data:
|
||||
raise HTTPException(status_code=404, detail=f"Could not generate thumbnail for {file_id}")
|
||||
|
||||
return Response(content=thumbnail_data, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600", "Content-Length": str(len(thumbnail_data))}) # Cache for 1 hour
|
||||
|
||||
async def validate_video(self, file_id: str) -> dict:
|
||||
"""Validate video file"""
|
||||
is_valid = await self.video_service.validate_video(file_id)
|
||||
return {"file_id": file_id, "is_valid": is_valid}
|
||||
|
||||
def _convert_to_response(self, video_file: VideoFile) -> VideoInfoResponse:
|
||||
"""Convert domain model to response model"""
|
||||
metadata_response = None
|
||||
if video_file.metadata:
|
||||
metadata_response = VideoMetadataResponse(duration_seconds=video_file.metadata.duration_seconds, width=video_file.metadata.width, height=video_file.metadata.height, fps=video_file.metadata.fps, codec=video_file.metadata.codec, bitrate=video_file.metadata.bitrate, aspect_ratio=video_file.metadata.aspect_ratio)
|
||||
|
||||
return VideoInfoResponse(
|
||||
file_id=video_file.file_id,
|
||||
camera_name=video_file.camera_name,
|
||||
filename=video_file.filename,
|
||||
file_size_bytes=video_file.file_size_bytes,
|
||||
format=video_file.format.value,
|
||||
status=video_file.status.value,
|
||||
created_at=video_file.created_at,
|
||||
start_time=video_file.start_time,
|
||||
end_time=video_file.end_time,
|
||||
machine_trigger=video_file.machine_trigger,
|
||||
metadata=metadata_response,
|
||||
is_streamable=video_file.is_streamable,
|
||||
needs_conversion=video_file.needs_conversion(),
|
||||
)
|
||||
|
||||
|
||||
class StreamingController:
|
||||
"""Controller for video streaming operations"""
|
||||
|
||||
def __init__(self, streaming_service: StreamingService, video_service: VideoService):
|
||||
self.streaming_service = streaming_service
|
||||
self.video_service = video_service
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_streaming_info(self, file_id: str) -> StreamingInfoResponse:
|
||||
"""Get streaming information for a video"""
|
||||
video_file = await self.streaming_service.get_video_info(file_id)
|
||||
if not video_file:
|
||||
raise HTTPException(status_code=404, detail=f"Video {file_id} not found")
|
||||
|
||||
chunk_size = await self.streaming_service.get_optimal_chunk_size(video_file.file_size_bytes)
|
||||
content_type = self._get_content_type(video_file)
|
||||
|
||||
return StreamingInfoResponse(file_id=file_id, file_size_bytes=video_file.file_size_bytes, content_type=content_type, supports_range_requests=True, chunk_size_bytes=chunk_size)
|
||||
|
||||
async def stream_video(self, file_id: str, request: Request) -> Response:
|
||||
"""Stream video with range request support"""
|
||||
# Prepare video for streaming (convert if needed)
|
||||
video_file = await self.video_service.prepare_for_streaming(file_id)
|
||||
if not video_file:
|
||||
raise HTTPException(status_code=404, detail=f"Video {file_id} not found or not streamable")
|
||||
|
||||
# Parse range header
|
||||
range_header = request.headers.get("range")
|
||||
range_request = None
|
||||
|
||||
if range_header:
|
||||
try:
|
||||
range_request = StreamRange.from_header(range_header, video_file.file_size_bytes)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=416, detail=f"Invalid range request: {e}")
|
||||
|
||||
# Determine response type and headers
|
||||
content_type = self._get_content_type(video_file)
|
||||
headers = {"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600"}
|
||||
|
||||
# Handle range requests for progressive streaming
|
||||
if range_request:
|
||||
# Validate range
|
||||
actual_range = self.streaming_service._validate_range(range_request, video_file.file_size_bytes)
|
||||
if not actual_range:
|
||||
raise HTTPException(status_code=416, detail="Range not satisfiable")
|
||||
|
||||
headers["Content-Range"] = self.streaming_service.calculate_content_range_header(actual_range, video_file.file_size_bytes)
|
||||
headers["Content-Length"] = str(actual_range.end - actual_range.start + 1)
|
||||
|
||||
# Create streaming generator for range
|
||||
async def generate_range():
|
||||
try:
|
||||
import aiofiles
|
||||
|
||||
async with aiofiles.open(video_file.file_path, "rb") as f:
|
||||
await f.seek(actual_range.start)
|
||||
remaining = actual_range.end - actual_range.start + 1
|
||||
chunk_size = min(8192, remaining) # 8KB chunks
|
||||
|
||||
while remaining > 0:
|
||||
chunk_size = min(chunk_size, remaining)
|
||||
chunk = await f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error streaming range for {file_id}: {e}")
|
||||
raise
|
||||
|
||||
return StreamingResponse(generate_range(), status_code=206, headers=headers, media_type=content_type)
|
||||
else:
|
||||
# Stream entire file
|
||||
headers["Content-Length"] = str(video_file.file_size_bytes)
|
||||
|
||||
async def generate_full():
|
||||
try:
|
||||
import aiofiles
|
||||
|
||||
async with aiofiles.open(video_file.file_path, "rb") as f:
|
||||
chunk_size = 8192 # 8KB chunks
|
||||
while True:
|
||||
chunk = await f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error streaming full file for {file_id}: {e}")
|
||||
raise
|
||||
|
||||
return StreamingResponse(generate_full(), status_code=200, headers=headers, media_type=content_type)
|
||||
|
||||
async def invalidate_cache(self, file_id: str) -> dict:
|
||||
"""Invalidate streaming cache for a video"""
|
||||
success = await self.streaming_service.invalidate_cache(file_id)
|
||||
return {"file_id": file_id, "cache_invalidated": success}
|
||||
|
||||
def _get_content_type(self, video_file: VideoFile) -> str:
|
||||
"""Get MIME content type for video file"""
|
||||
format_to_mime = {"avi": "video/x-msvideo", "mp4": "video/mp4", "webm": "video/webm"}
|
||||
return format_to_mime.get(video_file.format.value, "application/octet-stream")
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Video API Routes.
|
||||
|
||||
FastAPI route definitions for video streaming and management.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from .controllers import VideoController, StreamingController
|
||||
from .schemas import (
|
||||
VideoInfoResponse, VideoListResponse, VideoListRequest,
|
||||
StreamingInfoResponse, ThumbnailRequest
|
||||
)
|
||||
|
||||
|
||||
def create_video_routes(
|
||||
video_controller: VideoController,
|
||||
streaming_controller: StreamingController
|
||||
) -> APIRouter:
|
||||
"""Create video API routes with dependency injection"""
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
@router.get("/", response_model=VideoListResponse)
|
||||
async def list_videos(
|
||||
camera_name: Optional[str] = Query(None, description="Filter by camera name"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
|
||||
limit: Optional[int] = Query(50, description="Maximum number of results"),
|
||||
include_metadata: bool = Query(False, description="Include video metadata")
|
||||
):
|
||||
"""
|
||||
List videos with optional filters.
|
||||
|
||||
- **camera_name**: Filter videos by camera name
|
||||
- **start_date**: Filter videos created after this date
|
||||
- **end_date**: Filter videos created before this date
|
||||
- **limit**: Maximum number of videos to return
|
||||
- **include_metadata**: Whether to include video metadata (duration, resolution, etc.)
|
||||
"""
|
||||
request = VideoListRequest(
|
||||
camera_name=camera_name,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit,
|
||||
include_metadata=include_metadata
|
||||
)
|
||||
return await video_controller.list_videos(request)
|
||||
|
||||
@router.get("/{file_id}", response_model=VideoInfoResponse)
|
||||
async def get_video_info(file_id: str):
|
||||
"""
|
||||
Get detailed information about a specific video.
|
||||
|
||||
- **file_id**: Unique identifier for the video file
|
||||
"""
|
||||
return await video_controller.get_video_info(file_id)
|
||||
|
||||
@router.get("/{file_id}/stream")
|
||||
async def stream_video(file_id: str, request: Request):
|
||||
"""
|
||||
Stream video with HTTP range request support.
|
||||
|
||||
Supports:
|
||||
- **Range requests**: For seeking and progressive download
|
||||
- **Partial content**: 206 responses for range requests
|
||||
- **Format conversion**: Automatic conversion to web-compatible formats
|
||||
- **Caching**: Intelligent caching for better performance
|
||||
|
||||
Usage in HTML5:
|
||||
```html
|
||||
<video controls>
|
||||
<source src="/videos/{file_id}/stream" type="video/mp4">
|
||||
</video>
|
||||
```
|
||||
"""
|
||||
return await streaming_controller.stream_video(file_id, request)
|
||||
|
||||
@router.get("/{file_id}/info", response_model=StreamingInfoResponse)
|
||||
async def get_streaming_info(file_id: str):
|
||||
"""
|
||||
Get streaming information for a video.
|
||||
|
||||
Returns technical details needed for optimal streaming:
|
||||
- File size and content type
|
||||
- Range request support
|
||||
- Recommended chunk size
|
||||
"""
|
||||
return await streaming_controller.get_streaming_info(file_id)
|
||||
|
||||
@router.get("/{file_id}/thumbnail")
|
||||
async def get_video_thumbnail(
|
||||
file_id: str,
|
||||
timestamp: float = Query(1.0, description="Timestamp in seconds to extract thumbnail from"),
|
||||
width: int = Query(320, description="Thumbnail width in pixels"),
|
||||
height: int = Query(240, description="Thumbnail height in pixels")
|
||||
):
|
||||
"""
|
||||
Generate and return a thumbnail image from the video.
|
||||
|
||||
- **file_id**: Video file identifier
|
||||
- **timestamp**: Time position in seconds to extract thumbnail from
|
||||
- **width**: Thumbnail width in pixels
|
||||
- **height**: Thumbnail height in pixels
|
||||
|
||||
Returns JPEG image data.
|
||||
"""
|
||||
thumbnail_request = ThumbnailRequest(
|
||||
timestamp_seconds=timestamp,
|
||||
width=width,
|
||||
height=height
|
||||
)
|
||||
return await video_controller.get_video_thumbnail(file_id, thumbnail_request)
|
||||
|
||||
@router.post("/{file_id}/validate")
|
||||
async def validate_video(file_id: str):
|
||||
"""
|
||||
Validate that a video file is accessible and playable.
|
||||
|
||||
- **file_id**: Video file identifier
|
||||
|
||||
Returns validation status and any issues found.
|
||||
"""
|
||||
return await video_controller.validate_video(file_id)
|
||||
|
||||
@router.post("/{file_id}/cache/invalidate")
|
||||
async def invalidate_video_cache(file_id: str):
|
||||
"""
|
||||
Invalidate cached data for a video file.
|
||||
|
||||
Useful when a video file has been updated or replaced.
|
||||
|
||||
- **file_id**: Video file identifier
|
||||
"""
|
||||
return await streaming_controller.invalidate_cache(file_id)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def create_admin_video_routes(streaming_controller: StreamingController) -> APIRouter:
|
||||
"""Create admin routes for video management"""
|
||||
|
||||
router = APIRouter(prefix="/admin/videos", tags=["admin", "videos"])
|
||||
|
||||
@router.post("/cache/cleanup")
|
||||
async def cleanup_video_cache(
|
||||
max_size_mb: int = Query(100, description="Maximum cache size in MB")
|
||||
):
|
||||
"""
|
||||
Clean up video streaming cache.
|
||||
|
||||
Removes old cached data to keep cache size under the specified limit.
|
||||
|
||||
- **max_size_mb**: Maximum cache size to maintain
|
||||
"""
|
||||
entries_removed = await streaming_controller.streaming_service.cleanup_cache(max_size_mb)
|
||||
return {
|
||||
"cache_cleaned": True,
|
||||
"entries_removed": entries_removed,
|
||||
"max_size_mb": max_size_mb
|
||||
}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Video API Request/Response Schemas.
|
||||
|
||||
Pydantic models for API serialization and validation.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VideoMetadataResponse(BaseModel):
|
||||
"""Video metadata response model"""
|
||||
duration_seconds: float = Field(..., description="Video duration in seconds")
|
||||
width: int = Field(..., description="Video width in pixels")
|
||||
height: int = Field(..., description="Video height in pixels")
|
||||
fps: float = Field(..., description="Video frame rate")
|
||||
codec: str = Field(..., description="Video codec")
|
||||
bitrate: Optional[int] = Field(None, description="Video bitrate in bps")
|
||||
aspect_ratio: float = Field(..., description="Video aspect ratio")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"duration_seconds": 120.5,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"codec": "XVID",
|
||||
"bitrate": 5000000,
|
||||
"aspect_ratio": 1.777
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VideoInfoResponse(BaseModel):
|
||||
"""Video file information response"""
|
||||
file_id: str = Field(..., description="Unique file identifier")
|
||||
camera_name: str = Field(..., description="Camera that recorded the video")
|
||||
filename: str = Field(..., description="Original filename")
|
||||
file_size_bytes: int = Field(..., description="File size in bytes")
|
||||
format: str = Field(..., description="Video format (avi, mp4, webm)")
|
||||
status: str = Field(..., description="Video status")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
start_time: Optional[datetime] = Field(None, description="Recording start time")
|
||||
end_time: Optional[datetime] = Field(None, description="Recording end time")
|
||||
machine_trigger: Optional[str] = Field(None, description="Machine that triggered recording")
|
||||
metadata: Optional[VideoMetadataResponse] = Field(None, description="Video metadata")
|
||||
is_streamable: bool = Field(..., description="Whether video can be streamed")
|
||||
needs_conversion: bool = Field(..., description="Whether video needs format conversion")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"camera_name": "camera1",
|
||||
"filename": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"format": "avi",
|
||||
"status": "completed",
|
||||
"created_at": "2025-08-04T14:30:22",
|
||||
"start_time": "2025-08-04T14:30:22",
|
||||
"end_time": "2025-08-04T14:32:22",
|
||||
"machine_trigger": "vibratory_conveyor",
|
||||
"is_streamable": True,
|
||||
"needs_conversion": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VideoListResponse(BaseModel):
|
||||
"""Video list response"""
|
||||
videos: List[VideoInfoResponse] = Field(..., description="List of videos")
|
||||
total_count: int = Field(..., description="Total number of videos")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"videos": [],
|
||||
"total_count": 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StreamingInfoResponse(BaseModel):
|
||||
"""Streaming information response"""
|
||||
file_id: str = Field(..., description="Video file ID")
|
||||
file_size_bytes: int = Field(..., description="Total file size")
|
||||
content_type: str = Field(..., description="MIME content type")
|
||||
supports_range_requests: bool = Field(..., description="Whether range requests are supported")
|
||||
chunk_size_bytes: int = Field(..., description="Recommended chunk size for streaming")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"file_id": "camera1_recording_20250804_143022.avi",
|
||||
"file_size_bytes": 52428800,
|
||||
"content_type": "video/x-msvideo",
|
||||
"supports_range_requests": True,
|
||||
"chunk_size_bytes": 262144
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class VideoListRequest(BaseModel):
|
||||
"""Video list request parameters"""
|
||||
camera_name: Optional[str] = Field(None, description="Filter by camera name")
|
||||
start_date: Optional[datetime] = Field(None, description="Filter by start date")
|
||||
end_date: Optional[datetime] = Field(None, description="Filter by end date")
|
||||
limit: Optional[int] = Field(50, description="Maximum number of results")
|
||||
include_metadata: bool = Field(False, description="Include video metadata")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"camera_name": "camera1",
|
||||
"start_date": "2025-08-04T00:00:00",
|
||||
"end_date": "2025-08-04T23:59:59",
|
||||
"limit": 50,
|
||||
"include_metadata": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ThumbnailRequest(BaseModel):
|
||||
"""Thumbnail generation request"""
|
||||
timestamp_seconds: float = Field(1.0, description="Timestamp to extract thumbnail from")
|
||||
width: int = Field(320, description="Thumbnail width")
|
||||
height: int = Field(240, description="Thumbnail height")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"timestamp_seconds": 5.0,
|
||||
"width": 320,
|
||||
"height": 240
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user