Massive update - API and other modules added

This commit is contained in:
Alireza Vaezi
2025-07-25 21:39:07 -04:00
parent 172f46d44d
commit f6d6ba612e
70 changed files with 7276 additions and 15 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"]

Binary file not shown.

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

View File

@@ -0,0 +1,12 @@
"""
Camera module for the USDA Vision Camera System.
This module handles GigE camera discovery, management, monitoring, and recording
using the python demo library (mvsdk).
"""
from .manager import CameraManager
from .recorder import CameraRecorder
from .monitor import CameraMonitor
__all__ = ["CameraManager", "CameraRecorder", "CameraMonitor"]

View File

@@ -0,0 +1,320 @@
"""
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 python demo to path
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'python demo'))
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
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__)
# Camera management
self.available_cameras: List[Any] = [] # mvsdk camera device info
self.camera_recorders: Dict[str, CameraRecorder] = {} # camera_name -> recorder
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()
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()
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}")
continue
# 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_config.name] = recorder
self.logger.info(f"Initialized recorder for camera: {camera_config.name}")
except Exception as e:
self.logger.error(f"Error initializing recorder for {camera_config.name}: {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()
filename = f"{camera_name}_recording_{timestamp}.avi"
# 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) -> bool:
"""Manually start 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
if not filename:
timestamp = format_filename_timestamp()
filename = f"{camera_name}_manual_{timestamp}.avi"
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

View File

@@ -0,0 +1,267 @@
"""
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
from typing import Dict, List, Optional, Any
# Add python demo to path
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'python demo'))
import mvsdk
from ..core.config import Config
from ..core.state_manager import StateManager, CameraStatus
from ..core.events import EventSystem, publish_camera_status_changed
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
recorder = self.camera_manager.camera_recorders.get(camera_name)
if recorder and recorder.hCamera:
return "available", "Camera initialized and ready", 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:
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,372 @@
"""
Camera Recorder for the USDA Vision Camera System.
This module handles video recording from GigE cameras using the python demo library (mvsdk).
"""
import sys
import os
import threading
import time
import logging
import cv2
import numpy as np
from typing import Optional, Dict, Any
from datetime import datetime
from pathlib import Path
# Add python demo to path
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'python demo'))
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
class CameraRecorder:
"""Handles video recording for a single camera"""
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
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()
# Initialize camera
self._initialize_camera()
def _initialize_camera(self) -> bool:
"""Initialize the camera with configured settings"""
try:
self.logger.info(f"Initializing camera: {self.camera_config.name}")
# Initialize camera
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
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
self._configure_camera_settings()
# Allocate frame buffer
self.frame_buffer_size = (self.cap.sResolutionRange.iWidthMax *
self.cap.sResolutionRange.iHeightMax *
(1 if self.monoCamera else 3))
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:
self.logger.error(f"Camera initialization failed({e.error_code}): {e.message}")
return False
except Exception as e:
self.logger.error(f"Unexpected error during camera initialization: {e}")
return False
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)
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 start_recording(self, filename: str) -> bool:
"""Start video recording"""
with self._lock:
if self.recording:
self.logger.warning("Already recording!")
return False
if not self.hCamera:
self.logger.error("Camera not initialized")
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
)
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
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
fourcc = cv2.VideoWriter_fourcc(*'XVID')
frame_size = (FrameHead.iWidth, FrameHead.iHeight)
self.video_writer = cv2.VideoWriter(
self.output_filename,
fourcc,
self.camera_config.target_fps,
frame_size
)
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:
if self.monoCamera:
# Monochrome camera - convert to BGR
frame_data = np.frombuffer(self.frame_buffer, dtype=np.uint8)
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_data = np.frombuffer(self.frame_buffer, dtype=np.uint8)
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 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,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,207 @@
"""
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: 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
@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" # 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 = [
CameraConfig(**cam_data)
for cam_data in config_data['cameras']
]
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,328 @@
"""
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
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 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
@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)
# 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()
# 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() - datetime.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("=====================================")

288
usda_vision_system/main.py Normal file
View File

@@ -0,0 +1,288 @@
"""
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 .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.mqtt_client = MQTTClient(self.config, self.state_manager, self.event_system)
self.camera_manager = CameraManager(self.config, self.state_manager, self.event_system)
self.api_server = APIServer(
self.config, self.state_manager, self.event_system,
self.camera_manager, self.mqtt_client, self.storage_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 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, # We don't have SYSTEM_STARTED, using closest
"main_system",
{"action": "started", "timestamp": self.start_time.isoformat()}
)
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 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,251 @@
"""
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, Callable, List
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()
}
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()
# 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
self._subscribe_to_topics()
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"Subscribed to topic: {topic} (machine: {machine_name})")
else:
self.logger.error(f"Failed to subscribe to topic: {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("Successfully connected to MQTT broker")
else:
self.connected = False
self.logger.error(f"Failed to connect to MQTT broker, return 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"Unexpected MQTT disconnection (rc: {rc})")
else:
self.logger.info("MQTT client disconnected")
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
self.state_manager.update_mqtt_activity()
# Get machine name from topic
machine_name = self.topic_to_machine.get(topic)
if not machine_name:
self.logger.warning(f"Unknown topic: {topic}")
return
# Handle the message
self.message_handler.handle_message(machine_name, topic, payload)
except Exception as e:
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"""
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
}
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,153 @@
"""
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
)
# 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,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,349 @@
"""
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
class StorageManager:
"""Manages storage and file organization for recorded videos"""
def __init__(self, config: Config, state_manager: StateManager):
self.config = config
self.storage_config = config.storage
self.state_manager = state_manager
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()
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 _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: 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 = []
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())
# 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
}
# Analyze files by camera
for file_info in self.file_index["files"].values():
camera_name = file_info["camera_name"]
if camera_name not in stats["cameras"]:
stats["cameras"][camera_name] = {
"file_count": 0,
"total_size_bytes": 0,
"total_duration_seconds": 0
}
stats["total_files"] += 1
stats["cameras"][camera_name]["file_count"] += 1
if file_info.get("file_size_bytes"):
size = file_info["file_size_bytes"]
stats["total_size_bytes"] += size
stats["cameras"][camera_name]["total_size_bytes"] += size
if 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():
for video_file in storage_path.glob("*.avi"):
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