Massive update - API and other modules added
This commit is contained in:
13
usda_vision_system/__init__.py
Normal file
13
usda_vision_system/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
USDA Vision Camera System
|
||||
|
||||
A comprehensive system for monitoring machines via MQTT and automatically recording
|
||||
video from GigE cameras when machines are active.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "USDA Vision Team"
|
||||
|
||||
from .main import USDAVisionSystem
|
||||
|
||||
__all__ = ["USDAVisionSystem"]
|
||||
BIN
usda_vision_system/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
usda_vision_system/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/__pycache__/main.cpython-311.pyc
Normal file
BIN
usda_vision_system/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
10
usda_vision_system/api/__init__.py
Normal file
10
usda_vision_system/api/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
API module for the USDA Vision Camera System.
|
||||
|
||||
This module provides REST API endpoints and WebSocket support for dashboard integration.
|
||||
"""
|
||||
|
||||
from .server import APIServer
|
||||
from .models import *
|
||||
|
||||
__all__ = ["APIServer"]
|
||||
BIN
usda_vision_system/api/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
usda_vision_system/api/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/api/__pycache__/models.cpython-311.pyc
Normal file
BIN
usda_vision_system/api/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/api/__pycache__/server.cpython-311.pyc
Normal file
BIN
usda_vision_system/api/__pycache__/server.cpython-311.pyc
Normal file
Binary file not shown.
145
usda_vision_system/api/models.py
Normal file
145
usda_vision_system/api/models.py
Normal 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())
|
||||
426
usda_vision_system/api/server.py
Normal file
426
usda_vision_system/api/server.py
Normal 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)
|
||||
}
|
||||
12
usda_vision_system/camera/__init__.py
Normal file
12
usda_vision_system/camera/__init__.py
Normal 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"]
|
||||
BIN
usda_vision_system/camera/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
usda_vision_system/camera/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/camera/__pycache__/manager.cpython-311.pyc
Normal file
BIN
usda_vision_system/camera/__pycache__/manager.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/camera/__pycache__/monitor.cpython-311.pyc
Normal file
BIN
usda_vision_system/camera/__pycache__/monitor.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/camera/__pycache__/recorder.cpython-311.pyc
Normal file
BIN
usda_vision_system/camera/__pycache__/recorder.cpython-311.pyc
Normal file
Binary file not shown.
320
usda_vision_system/camera/manager.py
Normal file
320
usda_vision_system/camera/manager.py
Normal 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
|
||||
267
usda_vision_system/camera/monitor.py
Normal file
267
usda_vision_system/camera/monitor.py
Normal 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
|
||||
372
usda_vision_system/camera/recorder.py
Normal file
372
usda_vision_system/camera/recorder.py
Normal 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
|
||||
}
|
||||
15
usda_vision_system/core/__init__.py
Normal file
15
usda_vision_system/core/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
USDA Vision Camera System - Core Module
|
||||
|
||||
This module contains the core functionality for the USDA vision camera system,
|
||||
including configuration management, state management, and event handling.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "USDA Vision Team"
|
||||
|
||||
from .config import Config
|
||||
from .state_manager import StateManager
|
||||
from .events import EventSystem
|
||||
|
||||
__all__ = ["Config", "StateManager", "EventSystem"]
|
||||
BIN
usda_vision_system/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
usda_vision_system/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/core/__pycache__/config.cpython-311.pyc
Normal file
BIN
usda_vision_system/core/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/core/__pycache__/events.cpython-311.pyc
Normal file
BIN
usda_vision_system/core/__pycache__/events.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
207
usda_vision_system/core/config.py
Normal file
207
usda_vision_system/core/config.py
Normal 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]
|
||||
}
|
||||
195
usda_vision_system/core/events.py
Normal file
195
usda_vision_system/core/events.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Event system for the USDA Vision Camera System.
|
||||
|
||||
This module provides a thread-safe event system for communication between
|
||||
different components of the system (MQTT, cameras, recording, etc.).
|
||||
"""
|
||||
|
||||
import threading
|
||||
import logging
|
||||
from typing import Dict, List, Callable, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""Event types for the system"""
|
||||
MACHINE_STATE_CHANGED = "machine_state_changed"
|
||||
CAMERA_STATUS_CHANGED = "camera_status_changed"
|
||||
RECORDING_STARTED = "recording_started"
|
||||
RECORDING_STOPPED = "recording_stopped"
|
||||
RECORDING_ERROR = "recording_error"
|
||||
MQTT_CONNECTED = "mqtt_connected"
|
||||
MQTT_DISCONNECTED = "mqtt_disconnected"
|
||||
SYSTEM_SHUTDOWN = "system_shutdown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""Event data structure"""
|
||||
event_type: EventType
|
||||
source: str
|
||||
data: Dict[str, Any]
|
||||
timestamp: datetime
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.timestamp, datetime):
|
||||
self.timestamp = datetime.now()
|
||||
|
||||
|
||||
class EventSystem:
|
||||
"""Thread-safe event system for inter-component communication"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._subscribers: Dict[EventType, List[Callable]] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._event_history: List[Event] = []
|
||||
self._max_history = 1000 # Keep last 1000 events
|
||||
|
||||
def subscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None:
|
||||
"""Subscribe to an event type"""
|
||||
with self._lock:
|
||||
if event_type not in self._subscribers:
|
||||
self._subscribers[event_type] = []
|
||||
|
||||
if callback not in self._subscribers[event_type]:
|
||||
self._subscribers[event_type].append(callback)
|
||||
self.logger.debug(f"Subscribed to {event_type.value}")
|
||||
|
||||
def unsubscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None:
|
||||
"""Unsubscribe from an event type"""
|
||||
with self._lock:
|
||||
if event_type in self._subscribers:
|
||||
try:
|
||||
self._subscribers[event_type].remove(callback)
|
||||
self.logger.debug(f"Unsubscribed from {event_type.value}")
|
||||
except ValueError:
|
||||
pass # Callback wasn't subscribed
|
||||
|
||||
def publish(self, event_type: EventType, source: str, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Publish an event"""
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
event = Event(
|
||||
event_type=event_type,
|
||||
source=source,
|
||||
data=data,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
# Add to history
|
||||
with self._lock:
|
||||
self._event_history.append(event)
|
||||
if len(self._event_history) > self._max_history:
|
||||
self._event_history.pop(0)
|
||||
|
||||
# Notify subscribers
|
||||
self._notify_subscribers(event)
|
||||
|
||||
def _notify_subscribers(self, event: Event) -> None:
|
||||
"""Notify all subscribers of an event"""
|
||||
with self._lock:
|
||||
subscribers = self._subscribers.get(event.event_type, []).copy()
|
||||
|
||||
for callback in subscribers:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in event callback for {event.event_type.value}: {e}")
|
||||
|
||||
def get_recent_events(self, event_type: Optional[EventType] = None, limit: int = 100) -> List[Event]:
|
||||
"""Get recent events, optionally filtered by type"""
|
||||
with self._lock:
|
||||
events = self._event_history.copy()
|
||||
|
||||
if event_type:
|
||||
events = [e for e in events if e.event_type == event_type]
|
||||
|
||||
return events[-limit:] if limit else events
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear event history"""
|
||||
with self._lock:
|
||||
self._event_history.clear()
|
||||
self.logger.info("Event history cleared")
|
||||
|
||||
def get_subscriber_count(self, event_type: EventType) -> int:
|
||||
"""Get number of subscribers for an event type"""
|
||||
with self._lock:
|
||||
return len(self._subscribers.get(event_type, []))
|
||||
|
||||
def get_all_event_types(self) -> List[EventType]:
|
||||
"""Get all event types that have subscribers"""
|
||||
with self._lock:
|
||||
return list(self._subscribers.keys())
|
||||
|
||||
|
||||
# Global event system instance
|
||||
event_system = EventSystem()
|
||||
|
||||
|
||||
# Convenience functions for common events
|
||||
def publish_machine_state_changed(machine_name: str, state: str, source: str = "mqtt") -> None:
|
||||
"""Publish machine state change event"""
|
||||
event_system.publish(
|
||||
EventType.MACHINE_STATE_CHANGED,
|
||||
source,
|
||||
{
|
||||
"machine_name": machine_name,
|
||||
"state": state,
|
||||
"previous_state": None # Could be enhanced to track previous state
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_camera_status_changed(camera_name: str, status: str, details: str = "", source: str = "camera_monitor") -> None:
|
||||
"""Publish camera status change event"""
|
||||
event_system.publish(
|
||||
EventType.CAMERA_STATUS_CHANGED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"status": status,
|
||||
"details": details
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_started(camera_name: str, filename: str, source: str = "recorder") -> None:
|
||||
"""Publish recording started event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_STARTED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"filename": filename
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_stopped(camera_name: str, filename: str, duration_seconds: float, source: str = "recorder") -> None:
|
||||
"""Publish recording stopped event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_STOPPED,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"filename": filename,
|
||||
"duration_seconds": duration_seconds
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_recording_error(camera_name: str, error_message: str, source: str = "recorder") -> None:
|
||||
"""Publish recording error event"""
|
||||
event_system.publish(
|
||||
EventType.RECORDING_ERROR,
|
||||
source,
|
||||
{
|
||||
"camera_name": camera_name,
|
||||
"error_message": error_message
|
||||
}
|
||||
)
|
||||
260
usda_vision_system/core/logging_config.py
Normal file
260
usda_vision_system/core/logging_config.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Logging configuration for the USDA Vision Camera System.
|
||||
|
||||
This module provides comprehensive logging setup with rotation, formatting,
|
||||
and different log levels for different components.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""Colored formatter for console output"""
|
||||
|
||||
# ANSI color codes
|
||||
COLORS = {
|
||||
'DEBUG': '\033[36m', # Cyan
|
||||
'INFO': '\033[32m', # Green
|
||||
'WARNING': '\033[33m', # Yellow
|
||||
'ERROR': '\033[31m', # Red
|
||||
'CRITICAL': '\033[35m', # Magenta
|
||||
'RESET': '\033[0m' # Reset
|
||||
}
|
||||
|
||||
def format(self, record):
|
||||
# Add color to levelname
|
||||
if record.levelname in self.COLORS:
|
||||
record.levelname = f"{self.COLORS[record.levelname]}{record.levelname}{self.COLORS['RESET']}"
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class USDAVisionLogger:
|
||||
"""Custom logger setup for the USDA Vision Camera System"""
|
||||
|
||||
def __init__(self, log_level: str = "INFO", log_file: Optional[str] = None,
|
||||
enable_console: bool = True, enable_rotation: bool = True):
|
||||
self.log_level = log_level.upper()
|
||||
self.log_file = log_file
|
||||
self.enable_console = enable_console
|
||||
self.enable_rotation = enable_rotation
|
||||
|
||||
# Setup logging
|
||||
self._setup_logging()
|
||||
|
||||
def _setup_logging(self) -> None:
|
||||
"""Setup comprehensive logging configuration"""
|
||||
|
||||
# Get root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, self.log_level))
|
||||
|
||||
# Clear existing handlers
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Create formatters
|
||||
detailed_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
|
||||
simple_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
colored_formatter = ColoredFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
# Console handler
|
||||
if self.enable_console:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, self.log_level))
|
||||
console_handler.setFormatter(colored_formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
if self.log_file:
|
||||
try:
|
||||
# Create log directory if it doesn't exist
|
||||
log_dir = os.path.dirname(self.log_file)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
if self.enable_rotation:
|
||||
# Rotating file handler (10MB max, keep 5 backups)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_file,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5
|
||||
)
|
||||
else:
|
||||
file_handler = logging.FileHandler(self.log_file)
|
||||
|
||||
file_handler.setLevel(logging.DEBUG) # File gets all messages
|
||||
file_handler.setFormatter(detailed_formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not setup file logging: {e}")
|
||||
|
||||
# Setup specific logger levels for different components
|
||||
self._setup_component_loggers()
|
||||
|
||||
# Log the logging setup
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Logging initialized - Level: {self.log_level}, File: {self.log_file}")
|
||||
|
||||
def _setup_component_loggers(self) -> None:
|
||||
"""Setup specific log levels for different components"""
|
||||
|
||||
# MQTT client - can be verbose
|
||||
mqtt_logger = logging.getLogger('usda_vision_system.mqtt')
|
||||
if self.log_level == 'DEBUG':
|
||||
mqtt_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
mqtt_logger.setLevel(logging.INFO)
|
||||
|
||||
# Camera components - important for debugging
|
||||
camera_logger = logging.getLogger('usda_vision_system.camera')
|
||||
camera_logger.setLevel(logging.INFO)
|
||||
|
||||
# API server - can be noisy
|
||||
api_logger = logging.getLogger('usda_vision_system.api')
|
||||
if self.log_level == 'DEBUG':
|
||||
api_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
api_logger.setLevel(logging.INFO)
|
||||
|
||||
# Uvicorn - reduce noise unless debugging
|
||||
uvicorn_logger = logging.getLogger('uvicorn')
|
||||
if self.log_level == 'DEBUG':
|
||||
uvicorn_logger.setLevel(logging.INFO)
|
||||
else:
|
||||
uvicorn_logger.setLevel(logging.WARNING)
|
||||
|
||||
# FastAPI - reduce noise
|
||||
fastapi_logger = logging.getLogger('fastapi')
|
||||
fastapi_logger.setLevel(logging.WARNING)
|
||||
|
||||
@staticmethod
|
||||
def setup_exception_logging():
|
||||
"""Setup logging for uncaught exceptions"""
|
||||
|
||||
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
# Don't log keyboard interrupts
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
return
|
||||
|
||||
logger = logging.getLogger("uncaught_exception")
|
||||
logger.critical(
|
||||
"Uncaught exception",
|
||||
exc_info=(exc_type, exc_value, exc_traceback)
|
||||
)
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
|
||||
class PerformanceLogger:
|
||||
"""Logger for performance monitoring"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.logger = logging.getLogger(f"performance.{name}")
|
||||
self.start_time: Optional[float] = None
|
||||
|
||||
def start_timer(self, operation: str) -> None:
|
||||
"""Start timing an operation"""
|
||||
import time
|
||||
self.start_time = time.time()
|
||||
self.logger.debug(f"Started: {operation}")
|
||||
|
||||
def end_timer(self, operation: str) -> float:
|
||||
"""End timing an operation and log duration"""
|
||||
import time
|
||||
if self.start_time is None:
|
||||
self.logger.warning(f"Timer not started for: {operation}")
|
||||
return 0.0
|
||||
|
||||
duration = time.time() - self.start_time
|
||||
self.logger.info(f"Completed: {operation} in {duration:.3f}s")
|
||||
self.start_time = None
|
||||
return duration
|
||||
|
||||
def log_metric(self, metric_name: str, value: float, unit: str = "") -> None:
|
||||
"""Log a performance metric"""
|
||||
self.logger.info(f"Metric: {metric_name} = {value} {unit}")
|
||||
|
||||
|
||||
class ErrorTracker:
|
||||
"""Track and log errors with context"""
|
||||
|
||||
def __init__(self, component_name: str):
|
||||
self.component_name = component_name
|
||||
self.logger = logging.getLogger(f"errors.{component_name}")
|
||||
self.error_count = 0
|
||||
self.last_error_time: Optional[datetime] = None
|
||||
|
||||
def log_error(self, error: Exception, context: str = "",
|
||||
additional_data: Optional[dict] = None) -> None:
|
||||
"""Log an error with context and tracking"""
|
||||
self.error_count += 1
|
||||
self.last_error_time = datetime.now()
|
||||
|
||||
error_msg = f"Error in {self.component_name}"
|
||||
if context:
|
||||
error_msg += f" ({context})"
|
||||
error_msg += f": {str(error)}"
|
||||
|
||||
if additional_data:
|
||||
error_msg += f" | Data: {additional_data}"
|
||||
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
|
||||
def log_warning(self, message: str, context: str = "") -> None:
|
||||
"""Log a warning with context"""
|
||||
warning_msg = f"Warning in {self.component_name}"
|
||||
if context:
|
||||
warning_msg += f" ({context})"
|
||||
warning_msg += f": {message}"
|
||||
|
||||
self.logger.warning(warning_msg)
|
||||
|
||||
def get_error_stats(self) -> dict:
|
||||
"""Get error statistics"""
|
||||
return {
|
||||
"component": self.component_name,
|
||||
"error_count": self.error_count,
|
||||
"last_error_time": self.last_error_time.isoformat() if self.last_error_time else None
|
||||
}
|
||||
|
||||
|
||||
def setup_logging(log_level: str = "INFO", log_file: Optional[str] = None) -> USDAVisionLogger:
|
||||
"""Setup logging for the entire application"""
|
||||
|
||||
# Setup main logging
|
||||
logger_setup = USDAVisionLogger(
|
||||
log_level=log_level,
|
||||
log_file=log_file,
|
||||
enable_console=True,
|
||||
enable_rotation=True
|
||||
)
|
||||
|
||||
# Setup exception logging
|
||||
USDAVisionLogger.setup_exception_logging()
|
||||
|
||||
return logger_setup
|
||||
|
||||
|
||||
def get_performance_logger(component_name: str) -> PerformanceLogger:
|
||||
"""Get a performance logger for a component"""
|
||||
return PerformanceLogger(component_name)
|
||||
|
||||
|
||||
def get_error_tracker(component_name: str) -> ErrorTracker:
|
||||
"""Get an error tracker for a component"""
|
||||
return ErrorTracker(component_name)
|
||||
328
usda_vision_system/core/state_manager.py
Normal file
328
usda_vision_system/core/state_manager.py
Normal 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
|
||||
225
usda_vision_system/core/timezone_utils.py
Normal file
225
usda_vision_system/core/timezone_utils.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Timezone utilities for the USDA Vision Camera System.
|
||||
|
||||
This module provides timezone-aware datetime handling for Atlanta, Georgia.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import pytz
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class TimezoneManager:
|
||||
"""Manages timezone-aware datetime operations"""
|
||||
|
||||
def __init__(self, timezone_name: str = "America/New_York"):
|
||||
self.timezone_name = timezone_name
|
||||
self.timezone = pytz.timezone(timezone_name)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Log timezone information
|
||||
self.logger.info(f"Timezone manager initialized for {timezone_name}")
|
||||
self._log_timezone_info()
|
||||
|
||||
def _log_timezone_info(self) -> None:
|
||||
"""Log current timezone information"""
|
||||
now = self.now()
|
||||
self.logger.info(f"Current local time: {now}")
|
||||
self.logger.info(f"Current UTC time: {self.to_utc(now)}")
|
||||
self.logger.info(f"Timezone: {now.tzname()} (UTC{now.strftime('%z')})")
|
||||
|
||||
def now(self) -> datetime.datetime:
|
||||
"""Get current time in the configured timezone"""
|
||||
return datetime.datetime.now(self.timezone)
|
||||
|
||||
def utc_now(self) -> datetime.datetime:
|
||||
"""Get current UTC time"""
|
||||
return datetime.datetime.now(pytz.UTC)
|
||||
|
||||
def to_local(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert datetime to local timezone"""
|
||||
if dt.tzinfo is None:
|
||||
# Assume UTC if no timezone info
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.astimezone(self.timezone)
|
||||
|
||||
def to_utc(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert datetime to UTC"""
|
||||
if dt.tzinfo is None:
|
||||
# Assume local timezone if no timezone info
|
||||
dt = self.timezone.localize(dt)
|
||||
return dt.astimezone(pytz.UTC)
|
||||
|
||||
def localize(self, dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Add timezone info to naive datetime (assumes local timezone)"""
|
||||
if dt.tzinfo is not None:
|
||||
return dt
|
||||
return self.timezone.localize(dt)
|
||||
|
||||
def format_timestamp(self, dt: Optional[datetime.datetime] = None,
|
||||
include_timezone: bool = True) -> str:
|
||||
"""Format datetime as timestamp string"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
if include_timezone:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
else:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def format_filename_timestamp(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format datetime for use in filenames (no special characters)"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
def parse_timestamp(self, timestamp_str: str) -> datetime.datetime:
|
||||
"""Parse timestamp string to datetime"""
|
||||
try:
|
||||
# Try parsing with timezone info
|
||||
return datetime.datetime.fromisoformat(timestamp_str)
|
||||
except ValueError:
|
||||
try:
|
||||
# Try parsing without timezone (assume local)
|
||||
dt = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
return self.localize(dt)
|
||||
except ValueError:
|
||||
try:
|
||||
# Try parsing filename format
|
||||
dt = datetime.datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
|
||||
return self.localize(dt)
|
||||
except ValueError:
|
||||
raise ValueError(f"Unable to parse timestamp: {timestamp_str}")
|
||||
|
||||
def is_dst(self, dt: Optional[datetime.datetime] = None) -> bool:
|
||||
"""Check if datetime is during daylight saving time"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return bool(dt.dst())
|
||||
|
||||
def get_timezone_offset(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Get timezone offset string (e.g., '-0500' or '-0400')"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.strftime('%z')
|
||||
|
||||
def get_timezone_name(self, dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Get timezone name (e.g., 'EST' or 'EDT')"""
|
||||
if dt is None:
|
||||
dt = self.now()
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = self.localize(dt)
|
||||
|
||||
return dt.tzname()
|
||||
|
||||
|
||||
# Global timezone manager instance for Atlanta, Georgia
|
||||
atlanta_tz = TimezoneManager("America/New_York")
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def now_atlanta() -> datetime.datetime:
|
||||
"""Get current Atlanta time"""
|
||||
return atlanta_tz.now()
|
||||
|
||||
|
||||
def format_atlanta_timestamp(dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format timestamp in Atlanta timezone"""
|
||||
return atlanta_tz.format_timestamp(dt)
|
||||
|
||||
|
||||
def format_filename_timestamp(dt: Optional[datetime.datetime] = None) -> str:
|
||||
"""Format timestamp for filenames"""
|
||||
return atlanta_tz.format_filename_timestamp(dt)
|
||||
|
||||
|
||||
def to_atlanta_time(dt: datetime.datetime) -> datetime.datetime:
|
||||
"""Convert any datetime to Atlanta time"""
|
||||
return atlanta_tz.to_local(dt)
|
||||
|
||||
|
||||
def check_time_sync() -> dict:
|
||||
"""Check if system time appears to be synchronized"""
|
||||
import requests
|
||||
|
||||
result = {
|
||||
"system_time": now_atlanta(),
|
||||
"timezone": atlanta_tz.get_timezone_name(),
|
||||
"offset": atlanta_tz.get_timezone_offset(),
|
||||
"dst": atlanta_tz.is_dst(),
|
||||
"sync_status": "unknown",
|
||||
"time_diff_seconds": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
# Check against world time API
|
||||
response = requests.get(
|
||||
"http://worldtimeapi.org/api/timezone/America/New_York",
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
api_time = datetime.datetime.fromisoformat(data['datetime'])
|
||||
|
||||
# Convert to same timezone for comparison
|
||||
system_time = atlanta_tz.now()
|
||||
time_diff = abs((system_time.replace(tzinfo=None) -
|
||||
api_time.replace(tzinfo=None)).total_seconds())
|
||||
|
||||
result["api_time"] = api_time
|
||||
result["time_diff_seconds"] = time_diff
|
||||
|
||||
if time_diff < 5:
|
||||
result["sync_status"] = "synchronized"
|
||||
elif time_diff < 30:
|
||||
result["sync_status"] = "minor_drift"
|
||||
else:
|
||||
result["sync_status"] = "out_of_sync"
|
||||
else:
|
||||
result["error"] = f"API returned status {response.status_code}"
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def log_time_info(logger: Optional[logging.Logger] = None) -> None:
|
||||
"""Log comprehensive time information"""
|
||||
if logger is None:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sync_info = check_time_sync()
|
||||
|
||||
logger.info("=== TIME SYNCHRONIZATION STATUS ===")
|
||||
logger.info(f"System time: {sync_info['system_time']}")
|
||||
logger.info(f"Timezone: {sync_info['timezone']} ({sync_info['offset']})")
|
||||
logger.info(f"Daylight Saving: {'Yes' if sync_info['dst'] else 'No'}")
|
||||
logger.info(f"Sync status: {sync_info['sync_status']}")
|
||||
|
||||
if sync_info.get('time_diff_seconds') is not None:
|
||||
logger.info(f"Time difference: {sync_info['time_diff_seconds']:.2f} seconds")
|
||||
|
||||
if sync_info.get('error'):
|
||||
logger.warning(f"Time sync check error: {sync_info['error']}")
|
||||
|
||||
logger.info("=====================================")
|
||||
288
usda_vision_system/main.py
Normal file
288
usda_vision_system/main.py
Normal 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()
|
||||
11
usda_vision_system/mqtt/__init__.py
Normal file
11
usda_vision_system/mqtt/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
MQTT module for the USDA Vision Camera System.
|
||||
|
||||
This module handles MQTT communication for receiving machine state updates
|
||||
and triggering camera recording based on machine states.
|
||||
"""
|
||||
|
||||
from .client import MQTTClient
|
||||
from .handlers import MQTTMessageHandler
|
||||
|
||||
__all__ = ["MQTTClient", "MQTTMessageHandler"]
|
||||
BIN
usda_vision_system/mqtt/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
usda_vision_system/mqtt/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/mqtt/__pycache__/client.cpython-311.pyc
Normal file
BIN
usda_vision_system/mqtt/__pycache__/client.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/mqtt/__pycache__/handlers.cpython-311.pyc
Normal file
BIN
usda_vision_system/mqtt/__pycache__/handlers.cpython-311.pyc
Normal file
Binary file not shown.
251
usda_vision_system/mqtt/client.py
Normal file
251
usda_vision_system/mqtt/client.py
Normal 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
|
||||
153
usda_vision_system/mqtt/handlers.py
Normal file
153
usda_vision_system/mqtt/handlers.py
Normal 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
|
||||
9
usda_vision_system/storage/__init__.py
Normal file
9
usda_vision_system/storage/__init__.py
Normal 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"]
|
||||
BIN
usda_vision_system/storage/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
usda_vision_system/storage/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
usda_vision_system/storage/__pycache__/manager.cpython-311.pyc
Normal file
BIN
usda_vision_system/storage/__pycache__/manager.cpython-311.pyc
Normal file
Binary file not shown.
349
usda_vision_system/storage/manager.py
Normal file
349
usda_vision_system/storage/manager.py
Normal 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
|
||||
Reference in New Issue
Block a user