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

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

View File

@@ -0,0 +1,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"]

View 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()

View File

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

View File

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

View File

@@ -0,0 +1,779 @@
"""
FastAPI Server for the USDA Vision Camera System.
This module provides REST API endpoints and WebSocket support for dashboard integration.
"""
import asyncio
import logging
import json
from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta
import threading
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
import uvicorn
from ..core.config import Config
from ..core.state_manager import StateManager
from ..core.events import EventSystem, EventType, Event
from ..storage.manager import StorageManager
from ..video.integration import create_video_module, VideoModule
from .models import *
class WebSocketManager:
"""Manages WebSocket connections for real-time updates"""
def __init__(self):
self.active_connections: List[WebSocket] = []
self.logger = logging.getLogger(f"{__name__}.WebSocketManager")
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
self.logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
self.logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
async def send_personal_message(self, message: dict, websocket: WebSocket):
try:
await websocket.send_text(json.dumps(message))
except Exception as e:
self.logger.error(f"Error sending personal message: {e}")
async def broadcast(self, message: dict):
if not self.active_connections:
return
disconnected = []
for connection in self.active_connections:
try:
await connection.send_text(json.dumps(message))
except Exception as e:
self.logger.error(f"Error broadcasting to connection: {e}")
disconnected.append(connection)
# Remove disconnected connections
for connection in disconnected:
self.disconnect(connection)
class APIServer:
"""FastAPI server for the USDA Vision Camera System"""
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager, mqtt_client, storage_manager: StorageManager, auto_recording_manager=None):
self.config = config
self.state_manager = state_manager
self.event_system = event_system
self.camera_manager = camera_manager
self.mqtt_client = mqtt_client
self.storage_manager = storage_manager
self.auto_recording_manager = auto_recording_manager
self.logger = logging.getLogger(__name__)
# Initialize video module
self.video_module: Optional[VideoModule] = None
self._initialize_video_module()
# FastAPI app
self.app = FastAPI(title="USDA Vision Camera System API", description="API for monitoring and controlling the USDA vision camera system", version="1.0.0")
# WebSocket manager
self.websocket_manager = WebSocketManager()
# Server state
self.server_start_time = datetime.now()
self.running = False
self._server_thread: Optional[threading.Thread] = None
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
# Setup CORS
self.app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) # Configure appropriately for production
# Setup routes
self._setup_routes()
# Subscribe to events for WebSocket broadcasting
self._setup_event_subscriptions()
def _initialize_video_module(self):
"""Initialize the modular video streaming system"""
try:
self.video_module = create_video_module(config=self.config, storage_manager=self.storage_manager, enable_caching=True, enable_conversion=True)
self.logger.info("Video module initialized successfully")
except Exception as e:
self.logger.error(f"Failed to initialize video module: {e}")
self.video_module = None
def _setup_routes(self):
"""Setup API routes"""
@self.app.get("/", response_model=SuccessResponse)
async def root():
return SuccessResponse(message="USDA Vision Camera System API")
@self.app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
@self.app.get("/system/status", response_model=SystemStatusResponse)
async def get_system_status():
"""Get overall system status"""
try:
summary = self.state_manager.get_system_summary()
uptime = (datetime.now() - self.server_start_time).total_seconds()
return SystemStatusResponse(system_started=summary["system_started"], mqtt_connected=summary["mqtt_connected"], last_mqtt_message=summary["last_mqtt_message"], machines=summary["machines"], cameras=summary["cameras"], active_recordings=summary["active_recordings"], total_recordings=summary["total_recordings"], uptime_seconds=uptime)
except Exception as e:
self.logger.error(f"Error getting system status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/system/video-module")
async def get_video_module_status():
"""Get video module status and configuration"""
try:
if self.video_module:
status = self.video_module.get_module_status()
status["enabled"] = True
return status
else:
return {"enabled": False, "error": "Video module not initialized"}
except Exception as e:
self.logger.error(f"Error getting video module status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/machines", response_model=Dict[str, MachineStatusResponse])
async def get_machines():
"""Get all machine statuses"""
try:
machines = self.state_manager.get_all_machines()
return {name: MachineStatusResponse(name=machine.name, state=machine.state.value, last_updated=machine.last_updated.isoformat(), last_message=machine.last_message, mqtt_topic=machine.mqtt_topic) for name, machine in machines.items()}
except Exception as e:
self.logger.error(f"Error getting machines: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/mqtt/status", response_model=MQTTStatusResponse)
async def get_mqtt_status():
"""Get MQTT client status and statistics"""
try:
status = self.mqtt_client.get_status()
return MQTTStatusResponse(connected=status["connected"], broker_host=status["broker_host"], broker_port=status["broker_port"], subscribed_topics=status["subscribed_topics"], last_message_time=status["last_message_time"], message_count=status["message_count"], error_count=status["error_count"], uptime_seconds=status["uptime_seconds"])
except Exception as e:
self.logger.error(f"Error getting MQTT status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/mqtt/events", response_model=MQTTEventsHistoryResponse)
async def get_mqtt_events(limit: int = Query(default=5, ge=1, le=50, description="Number of recent events to retrieve")):
"""Get recent MQTT events history"""
try:
events = self.state_manager.get_recent_mqtt_events(limit)
total_events = self.state_manager.get_mqtt_event_count()
# Convert events to response format
event_responses = [MQTTEventResponse(machine_name=event.machine_name, topic=event.topic, payload=event.payload, normalized_state=event.normalized_state, timestamp=event.timestamp.isoformat(), message_number=event.message_number) for event in events]
last_updated = events[0].timestamp.isoformat() if events else None
return MQTTEventsHistoryResponse(events=event_responses, total_events=total_events, last_updated=last_updated)
except Exception as e:
self.logger.error(f"Error getting MQTT events: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras", response_model=Dict[str, CameraStatusResponse])
async def get_cameras():
"""Get all camera statuses"""
try:
cameras = self.state_manager.get_all_cameras()
return {
name: CameraStatusResponse(
name=camera.name,
status=camera.status.value,
is_recording=camera.is_recording,
last_checked=camera.last_checked.isoformat(),
last_error=camera.last_error,
device_info=camera.device_info,
current_recording_file=camera.current_recording_file,
recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None,
auto_recording_enabled=camera.auto_recording_enabled,
auto_recording_active=camera.auto_recording_active,
auto_recording_failure_count=camera.auto_recording_failure_count,
auto_recording_last_attempt=camera.auto_recording_last_attempt.isoformat() if camera.auto_recording_last_attempt else None,
auto_recording_last_error=camera.auto_recording_last_error,
)
for name, camera in cameras.items()
}
except Exception as e:
self.logger.error(f"Error getting cameras: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse)
async def get_camera_status(camera_name: str):
"""Get specific camera status"""
try:
camera = self.state_manager.get_camera_status(camera_name)
if not camera:
raise HTTPException(status_code=404, detail=f"Camera not found: {camera_name}")
return CameraStatusResponse(name=camera.name, status=camera.status.value, is_recording=camera.is_recording, last_checked=camera.last_checked.isoformat(), last_error=camera.last_error, device_info=camera.device_info, current_recording_file=camera.current_recording_file, recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error getting camera status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse)
async def start_recording(camera_name: str, request: StartRecordingRequest):
"""Manually start recording for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_start_recording(camera_name=camera_name, filename=request.filename, exposure_ms=request.exposure_ms, gain=request.gain, fps=request.fps)
if success:
# Get the actual filename that was used (with datetime prefix)
actual_filename = request.filename
if request.filename:
from ..core.timezone_utils import format_filename_timestamp
timestamp = format_filename_timestamp()
actual_filename = f"{timestamp}_{request.filename}"
return StartRecordingResponse(success=True, message=f"Recording started for {camera_name}", filename=actual_filename)
else:
return StartRecordingResponse(success=False, message=f"Failed to start recording for {camera_name}")
except Exception as e:
self.logger.error(f"Error starting recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse)
async def stop_recording(camera_name: str):
"""Manually stop recording for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_stop_recording(camera_name)
if success:
return StopRecordingResponse(success=True, message=f"Recording stopped for {camera_name}")
else:
return StopRecordingResponse(success=False, message=f"Failed to stop recording for {camera_name}")
except Exception as e:
self.logger.error(f"Error stopping recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/test-connection", response_model=CameraTestResponse)
async def test_camera_connection(camera_name: str):
"""Test camera connection"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.test_camera_connection(camera_name)
if success:
return CameraTestResponse(success=True, message=f"Camera {camera_name} connection test passed", camera_name=camera_name)
else:
return CameraTestResponse(success=False, message=f"Camera {camera_name} connection test failed", camera_name=camera_name)
except Exception as e:
self.logger.error(f"Error testing camera connection: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/stream")
async def camera_stream(camera_name: str):
"""Get live MJPEG stream from camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
# Get camera streamer
streamer = self.camera_manager.get_camera_streamer(camera_name)
if not streamer:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
# Start streaming if not already active
if not streamer.is_streaming():
success = streamer.start_streaming()
if not success:
raise HTTPException(status_code=500, detail=f"Failed to start streaming for camera {camera_name}")
# Return MJPEG stream
return StreamingResponse(streamer.get_frame_generator(), media_type="multipart/x-mixed-replace; boundary=frame")
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error starting camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-stream")
async def start_camera_stream(camera_name: str):
"""Start streaming for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.start_camera_streaming(camera_name)
if success:
return {"success": True, "message": f"Started streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to start streaming for camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error starting camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-stream")
async def stop_camera_stream(camera_name: str):
"""Stop streaming for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.stop_camera_streaming(camera_name)
if success:
return {"success": True, "message": f"Stopped streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to stop streaming for camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error stopping camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/config", response_model=CameraConfigResponse)
async def get_camera_config(camera_name: str):
"""Get camera configuration"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
config = self.camera_manager.get_camera_config(camera_name)
if not config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
return CameraConfigResponse(
name=config.name,
machine_topic=config.machine_topic,
storage_path=config.storage_path,
enabled=config.enabled,
# Auto-recording settings
auto_start_recording_enabled=config.auto_start_recording_enabled,
auto_recording_max_retries=config.auto_recording_max_retries,
auto_recording_retry_delay_seconds=config.auto_recording_retry_delay_seconds,
# Basic settings
exposure_ms=config.exposure_ms,
gain=config.gain,
target_fps=config.target_fps,
# Video recording settings
video_format=config.video_format,
video_codec=config.video_codec,
video_quality=config.video_quality,
# Image Quality Settings
sharpness=config.sharpness,
contrast=config.contrast,
saturation=config.saturation,
gamma=config.gamma,
# Noise Reduction
noise_filter_enabled=config.noise_filter_enabled,
denoise_3d_enabled=config.denoise_3d_enabled,
# Color Settings
auto_white_balance=config.auto_white_balance,
color_temperature_preset=config.color_temperature_preset,
# Manual White Balance RGB Gains
wb_red_gain=config.wb_red_gain,
wb_green_gain=config.wb_green_gain,
wb_blue_gain=config.wb_blue_gain,
# Advanced Settings
anti_flicker_enabled=config.anti_flicker_enabled,
light_frequency=config.light_frequency,
bit_depth=config.bit_depth,
# HDR Settings
hdr_enabled=config.hdr_enabled,
hdr_gain_mode=config.hdr_gain_mode,
)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error getting camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.put("/cameras/{camera_name}/config")
async def update_camera_config(camera_name: str, request: CameraConfigRequest):
"""Update camera configuration"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
# Convert request to dict, excluding None values
config_updates = {k: v for k, v in request.dict().items() if v is not None}
if not config_updates:
raise HTTPException(status_code=400, detail="No configuration updates provided")
success = self.camera_manager.update_camera_config(camera_name, **config_updates)
if success:
return {"success": True, "message": f"Camera {camera_name} configuration updated", "updated_settings": list(config_updates.keys())}
else:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found or update failed")
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error updating camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/apply-config")
async def apply_camera_config(camera_name: str):
"""Apply current configuration to active camera (requires camera restart)"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.apply_camera_config(camera_name)
if success:
return {"success": True, "message": f"Configuration applied to camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to apply configuration to camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error applying camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reconnect", response_model=CameraRecoveryResponse)
async def reconnect_camera(camera_name: str):
"""Reconnect to a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reconnect_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reconnected successfully", camera_name=camera_name, operation="reconnect")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reconnect camera {camera_name}", camera_name=camera_name, operation="reconnect")
except Exception as e:
self.logger.error(f"Error reconnecting camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/restart-grab", response_model=CameraRecoveryResponse)
async def restart_camera_grab(camera_name: str):
"""Restart camera grab process"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.restart_camera_grab(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} grab process restarted successfully", camera_name=camera_name, operation="restart-grab")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to restart grab process for camera {camera_name}", camera_name=camera_name, operation="restart-grab")
except Exception as e:
self.logger.error(f"Error restarting camera grab: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reset-timestamp", response_model=CameraRecoveryResponse)
async def reset_camera_timestamp(camera_name: str):
"""Reset camera timestamp"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reset_camera_timestamp(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} timestamp reset successfully", camera_name=camera_name, operation="reset-timestamp")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reset timestamp for camera {camera_name}", camera_name=camera_name, operation="reset-timestamp")
except Exception as e:
self.logger.error(f"Error resetting camera timestamp: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/full-reset", response_model=CameraRecoveryResponse)
async def full_reset_camera(camera_name: str):
"""Perform full camera reset (uninitialize and reinitialize)"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.full_reset_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} full reset completed successfully", camera_name=camera_name, operation="full-reset")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to perform full reset for camera {camera_name}", camera_name=camera_name, operation="full-reset")
except Exception as e:
self.logger.error(f"Error performing full camera reset: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reinitialize", response_model=CameraRecoveryResponse)
async def reinitialize_camera(camera_name: str):
"""Reinitialize a failed camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reinitialize_failed_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reinitialized successfully", camera_name=camera_name, operation="reinitialize")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reinitialize camera {camera_name}", camera_name=camera_name, operation="reinitialize")
except Exception as e:
self.logger.error(f"Error reinitializing camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/auto-recording/enable", response_model=AutoRecordingConfigResponse)
async def enable_auto_recording(camera_name: str):
"""Enable auto-recording for a camera"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
# Update camera configuration
camera_config = self.config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
camera_config.auto_start_recording_enabled = True
self.config.save_config()
# Update camera status in state manager
camera_info = self.state_manager.get_camera_status(camera_name)
if camera_info:
camera_info.auto_recording_enabled = True
return AutoRecordingConfigResponse(success=True, message=f"Auto-recording enabled for camera {camera_name}", camera_name=camera_name, enabled=True)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error enabling auto-recording for camera {camera_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/auto-recording/disable", response_model=AutoRecordingConfigResponse)
async def disable_auto_recording(camera_name: str):
"""Disable auto-recording for a camera"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
# Update camera configuration
camera_config = self.config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
camera_config.auto_start_recording_enabled = False
self.config.save_config()
# Update camera status in state manager
camera_info = self.state_manager.get_camera_status(camera_name)
if camera_info:
camera_info.auto_recording_enabled = False
camera_info.auto_recording_active = False
return AutoRecordingConfigResponse(success=True, message=f"Auto-recording disabled for camera {camera_name}", camera_name=camera_name, enabled=False)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error disabling auto-recording for camera {camera_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/auto-recording/status", response_model=AutoRecordingStatusResponse)
async def get_auto_recording_status():
"""Get auto-recording manager status"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
status = self.auto_recording_manager.get_status()
return AutoRecordingStatusResponse(**status)
except Exception as e:
self.logger.error(f"Error getting auto-recording status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/recordings", response_model=Dict[str, RecordingInfoResponse])
async def get_recordings():
"""Get all recording sessions"""
try:
recordings = self.state_manager.get_all_recordings()
return {
rid: RecordingInfoResponse(
camera_name=recording.camera_name,
filename=recording.filename,
start_time=recording.start_time.isoformat(),
state=recording.state.value,
end_time=recording.end_time.isoformat() if recording.end_time else None,
file_size_bytes=recording.file_size_bytes,
frame_count=recording.frame_count,
duration_seconds=(recording.end_time - recording.start_time).total_seconds() if recording.end_time else None,
error_message=recording.error_message,
)
for rid, recording in recordings.items()
}
except Exception as e:
self.logger.error(f"Error getting recordings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/storage/stats", response_model=StorageStatsResponse)
async def get_storage_stats():
"""Get storage statistics"""
try:
stats = self.storage_manager.get_storage_statistics()
return StorageStatsResponse(**stats)
except Exception as e:
self.logger.error(f"Error getting storage stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/files", response_model=FileListResponse)
async def get_files(request: FileListRequest):
"""Get list of recording files"""
try:
start_date = None
end_date = None
if request.start_date:
start_date = datetime.fromisoformat(request.start_date)
if request.end_date:
end_date = datetime.fromisoformat(request.end_date)
files = self.storage_manager.get_recording_files(camera_name=request.camera_name, start_date=start_date, end_date=end_date, limit=request.limit)
return FileListResponse(files=files, total_count=len(files))
except Exception as e:
self.logger.error(f"Error getting files: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/cleanup", response_model=CleanupResponse)
async def cleanup_storage(request: CleanupRequest):
"""Clean up old storage files"""
try:
result = self.storage_manager.cleanup_old_files(request.max_age_days)
return CleanupResponse(**result)
except Exception as e:
self.logger.error(f"Error during cleanup: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time updates"""
await self.websocket_manager.connect(websocket)
try:
while True:
# Keep connection alive and handle incoming messages
data = await websocket.receive_text()
# Echo back for now - could implement commands later
await self.websocket_manager.send_personal_message({"type": "echo", "data": data}, websocket)
except WebSocketDisconnect:
self.websocket_manager.disconnect(websocket)
# Include video module routes if available
if self.video_module:
try:
video_routes = self.video_module.get_api_routes()
admin_video_routes = self.video_module.get_admin_routes()
self.app.include_router(video_routes)
self.app.include_router(admin_video_routes)
self.logger.info("Video streaming routes added successfully")
except Exception as e:
self.logger.error(f"Failed to add video routes: {e}")
def _setup_event_subscriptions(self):
"""Setup event subscriptions for WebSocket broadcasting"""
def broadcast_event(event: Event):
"""Broadcast event to all WebSocket connections"""
try:
message = {"type": "event", "event_type": event.event_type.value, "source": event.source, "data": event.data, "timestamp": event.timestamp.isoformat()}
# Schedule the broadcast in the event loop thread-safely
if self._event_loop and not self._event_loop.is_closed():
# Use call_soon_threadsafe to schedule the coroutine from another thread
asyncio.run_coroutine_threadsafe(self.websocket_manager.broadcast(message), self._event_loop)
else:
self.logger.debug("Event loop not available for broadcasting")
except Exception as e:
self.logger.error(f"Error broadcasting event: {e}")
# Subscribe to all event types for broadcasting
for event_type in EventType:
self.event_system.subscribe(event_type, broadcast_event)
def start(self) -> bool:
"""Start the API server"""
if self.running:
self.logger.warning("API server is already running")
return True
if not self.config.system.enable_api:
self.logger.info("API server disabled in configuration")
return False
try:
self.logger.info(f"Starting API server on {self.config.system.api_host}:{self.config.system.api_port}")
self.running = True
# Start server in separate thread
self._server_thread = threading.Thread(target=self._run_server, daemon=True)
self._server_thread.start()
return True
except Exception as e:
self.logger.error(f"Error starting API server: {e}")
return False
def stop(self) -> None:
"""Stop the API server"""
if not self.running:
return
self.logger.info("Stopping API server...")
self.running = False
# Clean up video module
if self.video_module:
try:
# Note: This is synchronous cleanup - in a real async context you'd await this
asyncio.run(self.video_module.cleanup())
self.logger.info("Video module cleanup completed")
except Exception as e:
self.logger.error(f"Error during video module cleanup: {e}")
# Note: uvicorn doesn't have a clean way to stop from another thread
# In production, you might want to use a process manager like gunicorn
self.logger.info("API server stopped")
def _run_server(self) -> None:
"""Run the uvicorn server"""
try:
# Capture the event loop for thread-safe event broadcasting
self._event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._event_loop)
uvicorn.run(self.app, host=self.config.system.api_host, port=self.config.system.api_port, log_level="info")
except Exception as e:
self.logger.error(f"Error running API server: {e}")
finally:
self.running = False
self._event_loop = None
def is_running(self) -> bool:
"""Check if API server is running"""
return self.running
def get_server_info(self) -> Dict[str, Any]:
"""Get server information"""
return {"running": self.running, "host": self.config.system.api_host, "port": self.config.system.api_port, "start_time": self.server_start_time.isoformat(), "uptime_seconds": (datetime.now() - self.server_start_time).total_seconds(), "websocket_connections": len(self.websocket_manager.active_connections)}

View 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"]

View 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

View 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

View 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}

View File

@@ -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

View 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()

View 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"]

View 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]}

View 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
}
)

View 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)

View 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

View 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("=====================================")

View 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()

View 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"]

View 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

View 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

View File

@@ -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"]

View File

@@ -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]}

View File

@@ -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()

View File

@@ -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"]

View 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

View 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"]

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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

View 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}"

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View 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
)

View File

@@ -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",
]

View File

@@ -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")

View File

@@ -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

View File

@@ -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
}
}