Refactor API route setup and enhance modularity

- Consolidated API route definitions by registering routes from separate modules for better organization and maintainability.
- Removed redundant route definitions from the APIServer class, improving code clarity.
- Updated camera monitoring and recording modules to utilize a shared context manager for suppressing camera SDK errors, enhancing error handling.
- Adjusted timeout settings in camera operations for improved reliability during frame capture.
- Enhanced logging and error handling across camera operations to facilitate better debugging and monitoring.
This commit is contained in:
salirezav
2025-11-01 15:53:01 -04:00
parent 1a8aa8a027
commit f1a9cb0c1e
17 changed files with 2199 additions and 681 deletions

View File

@@ -0,0 +1,437 @@
# Code Quality Improvements - Simple & Safe Refactorings
## 📊 Current Codebase Analysis
### Largest Files (Focus Areas)
- `recorder.py`: 1,122 lines ⚠️
- `server.py`: 842 lines ⚠️
- `streamer.py`: 745 lines ⚠️
- `manager.py`: 614 lines
### Key Observations
1. **Large classes** with multiple responsibilities
2. **API routes** all in one method (`_setup_routes` - 700+ lines)
3. **Duplicate code** (camera error suppression, initialization)
4. **Long methods** (recording loop, streaming loop)
5. **Mixed concerns** (camera hardware + business logic)
---
## 🎯 Simple, Safe Refactorings (No Behavior Changes)
### 1. **Extract Duplicate Code** ⭐ Easy Win
**Problem**: `suppress_camera_errors()` appears in 3+ files
**Solution**: Move to shared utility
```python
# Before: Duplicate in recorder.py, streamer.py, monitor.py
@contextlib.contextmanager
def suppress_camera_errors():
# ... 20 lines duplicated
```
```python
# After: Single source in camera/utils.py
# camera/utils.py
@contextlib.contextmanager
def suppress_camera_errors():
"""Suppress camera SDK error output"""
# ... implementation
# Then import everywhere:
from .utils import suppress_camera_errors
```
**Impact**:
- ✅ Reduces duplication
- ✅ Single place to fix bugs
- ✅ No behavior change
- ⚡ 5 minutes, zero risk
---
### 2. **Split API Routes into Modules** ⭐ High Impact
**Problem**: `_setup_routes()` has 700+ lines, hard to navigate
**Current Structure**:
```python
# api/server.py
def _setup_routes(self):
@self.app.get("/cameras/...")
async def get_cameras(): ...
@self.app.post("/cameras/...")
async def start_recording(): ...
# ... 30+ more routes
```
**Solution**: Group routes by domain
```python
# api/server.py
def _setup_routes(self):
from .routes import camera_routes, recording_routes, system_routes
camera_routes.register(self.app, self.camera_manager)
recording_routes.register(self.app, self.camera_manager)
system_routes.register(self.app, self.state_manager, ...)
# api/routes/camera_routes.py
def register(app, camera_manager):
@app.get("/cameras")
async def get_cameras():
return camera_manager.get_all_camera_status()
# ... all camera routes
```
**Impact**:
- ✅ Much easier to find/edit routes
- ✅ Clearer organization
- ✅ Can split across files
- ⚡ 30 minutes, low risk
---
### 3. **Extract Camera Initialization** ⭐ Medium Impact
**Problem**: Camera initialization code duplicated in `recorder.py`, `streamer.py`, `monitor.py`
**Solution**: Create `CameraInitializer` class
```python
# camera/initializer.py
class CameraInitializer:
"""Handles camera initialization with consistent configuration"""
def __init__(self, camera_config: CameraConfig, device_info):
self.camera_config = camera_config
self.device_info = device_info
def initialize(self) -> tuple[int, Any, bool]:
"""Returns (hCamera, cap, monoCamera)"""
# Common initialization logic
# ...
return hCamera, cap, monoCamera
def configure_settings(self, hCamera):
"""Configure camera settings"""
# Common configuration
# ...
# Then use:
initializer = CameraInitializer(config, device_info)
self.hCamera, self.cap, self.monoCamera = initializer.initialize()
initializer.configure_settings(self.hCamera)
```
**Impact**:
- ✅ Removes ~200 lines of duplication
- ✅ Consistent initialization
- ✅ Easier to test
- ⚡ 1 hour, medium risk (test carefully)
---
### 4. **Break Down Large Methods** ⭐ Medium Impact
**Problem**: `_recording_loop()` is 100+ lines, `_streaming_loop()` is 80+ lines
**Solution**: Extract frame processing logic
```python
# Before: recorder.py
def _recording_loop(self, use_streamer_frames: bool):
while not self._stop_recording_event.is_set():
# Get frame (30 lines)
if use_streamer_frames:
# ... complex logic
else:
# ... complex logic
# Write frame (10 lines)
# Rate control (5 lines)
```
```python
# After: recorder.py
def _recording_loop(self, use_streamer_frames: bool):
frame_source = self._create_frame_source(use_streamer_frames)
while not self._stop_recording_event.is_set():
frame = frame_source.get_frame()
if frame:
self._write_frame(frame)
self._control_frame_rate()
def _create_frame_source(self, use_streamer_frames):
"""Create appropriate frame source"""
if use_streamer_frames:
return StreamerFrameSource(self.streamer)
return DirectCameraSource(self.hCamera, self.frame_buffer)
def _write_frame(self, frame):
"""Write frame to video"""
if self.video_writer:
self.video_writer.write(frame)
self.frame_count += 1
```
**Impact**:
- ✅ Methods are shorter, clearer
- ✅ Easier to test individual pieces
- ✅ Better readability
- ⚡ 2 hours, medium risk
---
### 5. **Type Hints & Documentation** ⭐ Easy, Incremental
**Problem**: Some methods lack type hints, unclear parameter meanings
**Solution**: Add type hints gradually (no behavior change)
```python
# Before
def start_recording(self, filename):
# ...
# After
def start_recording(self, filename: str) -> bool:
"""
Start video recording for the camera.
Args:
filename: Output filename (will be prefixed with timestamp)
Returns:
True if recording started successfully, False otherwise
Raises:
CameraException: If camera initialization fails
"""
```
**Impact**:
- ✅ Better IDE support
- ✅ Self-documenting code
- ✅ Catch errors earlier
- ⚡ Can do incrementally, zero risk
---
### 6. **Create Value Objects** ⭐ Medium Impact
**Problem**: Camera properties scattered across instance variables
**Current**:
```python
self.hCamera = ...
self.cap = ...
self.monoCamera = ...
self.frame_buffer = ...
self.frame_buffer_size = ...
```
**Better**:
```python
# camera/domain/camera_handle.py
@dataclass
class CameraHandle:
"""Represents a camera hardware connection"""
handle: int
capabilities: Any # Camera capability struct
is_monochrome: bool
frame_buffer: Any
frame_buffer_size: int
def is_valid(self) -> bool:
return self.handle is not None
# Usage
self.camera = CameraHandle(...)
if self.camera.is_valid():
# ...
```
**Impact**:
- ✅ Groups related data
- ✅ Easier to pass around
- ✅ Better encapsulation
- ⚡ 2 hours, low risk
---
### 7. **Extract Constants** ⭐ Easy Win
**Problem**: Magic numbers scattered throughout
```python
# Before
time.sleep(0.1)
timeout=200
maxsize=5
# After: camera/constants.py
STREAMING_LOOP_SLEEP = 0.1 # seconds
CAMERA_GET_BUFFER_TIMEOUT = 200 # milliseconds
FRAME_QUEUE_MAXSIZE = 5
```
**Impact**:
- ✅ Self-documenting
- ✅ Easy to tune
- ✅ No behavior change
- ⚡ 30 minutes, zero risk
---
## 🚀 Recommended Refactoring Order (Safe → Risky)
### Phase 1: **Quick Wins** (1-2 hours, zero risk)
1. ✅ Extract `suppress_camera_errors()` to shared utils
2. ✅ Extract constants to `constants.py`
3. ✅ Add type hints to public methods
### Phase 2: **Organization** (3-4 hours, low risk)
4. ✅ Split API routes into modules (`routes/camera_routes.py`, etc.)
5. ✅ Group related functions into utility modules
6. ✅ Improve docstrings
### Phase 3: **Structure** (6-8 hours, medium risk)
7. ✅ Extract `CameraInitializer` class
8. ✅ Break down large methods (`_recording_loop`, `_streaming_loop`)
9. ✅ Create value objects for camera handles
### Phase 4: **Advanced** (optional, higher risk)
10. ⚠️ Extract frame source abstractions
11. ⚠️ Create repository pattern for camera access
12. ⚠️ Dependency injection container
---
## 📋 Specific File Improvements
### `recorder.py` (1,122 lines)
**Quick wins**:
- Extract `suppress_camera_errors` → utils
- Extract constants (timeouts, buffer sizes)
- Split `_initialize_video_writer` (100+ lines) into smaller methods
**Medium refactoring**:
- Extract `_recording_loop` frame source logic
- Create `VideoWriterManager` class
- Extract camera configuration to separate class
### `server.py` (842 lines)
**Quick wins**:
- Split `_setup_routes` into route modules
- Extract WebSocket logic to separate file
- Group related endpoints
### `streamer.py` (745 lines)
**Quick wins**:
- Extract `suppress_camera_errors` → utils
- Extract RTSP FFmpeg command building
- Extract frame queue management
**Medium refactoring**:
- Extract `_streaming_loop` frame processing
- Create `FrameQueueManager` class
### `manager.py` (614 lines)
**Quick wins**:
- Extract camera discovery to separate class
- Split initialization methods
- Extract status reporting
---
## 🎯 "Good Enough" Approach
**Don't over-engineer!** Focus on:
1. **Readability**: Can a new developer understand it?
2. **Editability**: Can you change one thing without breaking others?
3. **Testability**: Can you test pieces in isolation?
**Avoid**:
- ❌ Premature abstraction
- ❌ Design patterns "just because"
- ❌ Perfect code (good enough > perfect)
- ❌ Breaking working code
---
## 💡 Minimal Viable Refactoring
**If you only do 3 things:**
1. **Extract duplicate code** (`suppress_camera_errors`, constants)
- 30 minutes, huge improvement
2. **Split API routes** (into route modules)
- 1 hour, makes API much more manageable
3. **Add type hints** (gradually, as you touch code)
- Ongoing, improves IDE support
**Result**: Much more maintainable code with minimal effort!
---
## 🔧 Tools to Help
### Code Quality Tools
```bash
# Linting
pip install ruff black mypy
ruff check .
black --check .
mypy camera-management-api/
# Complexity
pip install radon
radon cc camera-management-api/usda_vision_system -a
```
### Refactoring Safely
- ✅ Write tests first (if not present)
- ✅ Refactor in small steps
- ✅ Test after each change
- ✅ Use git branches
- ✅ Review diffs carefully
---
## 📊 Success Metrics
After refactoring, you should see:
- ✅ Lower cyclomatic complexity
- ✅ Smaller average method length
- ✅ Less duplicate code
- ✅ More consistent patterns
- ✅ Easier to add features
- ✅ Fewer bugs when making changes
---
## 🎓 Best Practices
1. **One refactoring per commit** - easier to review/rollback
2. **Don't refactor while adding features** - separate PRs
3. **Measure before/after** - use code metrics
4. **Document decisions** - why this structure?
5. **Keep it simple** - don't add complexity "for the future"
---
**Bottom Line**: Start with #1, #2, and #3 (duplicate extraction, route splitting, type hints). These give you 80% of the benefit with 20% of the effort, and they're completely safe!

427
MODULARIZATION_PROPOSAL.md Normal file
View File

@@ -0,0 +1,427 @@
# Camera Management API - Modularization Proposal
## 📊 Current Architecture Analysis
### Current Structure
The `camera-management-api` is currently a **monolithic service** with the following components:
1. **API Server** (`api/server.py`)
- FastAPI REST endpoints
- WebSocket real-time updates
- Orchestrates all other components
2. **Camera Management** (`camera/`)
- Camera discovery & initialization
- Recording (CameraRecorder)
- Streaming (CameraStreamer) - MJPEG & RTSP
- Camera monitoring & recovery
3. **MQTT Client** (`mqtt/`)
- Machine state monitoring
- Event publishing
4. **Storage Manager** (`storage/`)
- File indexing
- Storage statistics
- Cleanup operations
5. **Auto Recording Manager** (`recording/`)
- Automated recording based on MQTT events
- Standalone auto-recorder
6. **Core Services**
- State Manager (in-memory state)
- Event System (pub/sub)
- Configuration management
7. **Video Services** (`video/`)
- Video streaming
- Metadata extraction
- Caching
### Current Service Separation
You already have:
-`media-api` - Video processing (thumbnails, transcoding)
-`mediamtx` - RTSP streaming server
- ✅ Microfrontend dashboard (shell + video-remote)
---
## 🎯 Modularization Strategies
### Strategy 1: **Modular Monolith** (Recommended to Start)
**Approach**: Keep as single service but improve internal structure with clear boundaries.
**Structure**:
```
camera-management-api/
├── core/ # Shared infrastructure
│ ├── state/
│ ├── events/
│ └── config/
├── camera/ # Camera hardware layer
├── recording/ # Recording logic
├── streaming/ # Streaming logic (separate from camera)
├── mqtt/ # MQTT integration
├── storage/ # Storage operations
└── api/ # API endpoints (orchestration layer)
```
**Pros**:
- ✅ Minimal disruption to working system
- ✅ Easier debugging (single process)
- ✅ Lower operational complexity
- ✅ Shared state remains simple
- ✅ No network latency between components
- ✅ Easier to maintain consistency
**Cons**:
- ❌ Can't scale components independently
- ❌ All-or-nothing deployment
- ❌ Single point of failure (mitigated by Docker)
**Best For**: Current state - proven system that works well together
---
### Strategy 2: **Strategic Microservices** (Hybrid Approach)
**Approach**: Split only high-value, independently scalable components.
**Services**:
#### Service 1: **camera-service** (Critical, Hardware-Dependent)
```
Responsibilities:
- Camera discovery & initialization
- Recording (CameraRecorder)
- Streaming (CameraStreamer) - MJPEG
- Camera monitoring & recovery
- Hardware state management
Port: 8001
Dependencies: Camera SDK, FFmpeg
Network: host (for camera access)
```
#### Service 2: **mqtt-service** (Stateless, Scalable)
```
Responsibilities:
- MQTT client & subscriptions
- Machine state monitoring
- Event publishing
Port: 8002
Dependencies: MQTT broker
Stateless: Yes
```
#### Service 3: **api-gateway** (Orchestration)
```
Responsibilities:
- REST API endpoints
- WebSocket server
- Request routing to services
- Aggregating responses
Port: 8000
Dependencies: camera-service, mqtt-service, state-manager
```
#### Service 4: **state-manager-service** (Optional - Shared State)
```
Responsibilities:
- Centralized state management
- Event bus/queue
- State persistence
Port: 8003
Database: Redis (recommended) or PostgreSQL
```
**Pros**:
- ✅ Camera service can be isolated/restarted independently
- ✅ MQTT service is stateless and scalable
- ✅ Clear separation of concerns
- ✅ Can scale MQTT service separately
- ✅ API gateway can handle load balancing
**Cons**:
- ❌ More complex deployment
- ❌ Network latency between services
- ❌ State synchronization challenges
- ❌ More containers to manage
- ❌ Service discovery needed
**Best For**: Future scaling needs, when you need:
- Multiple camera servers
- High MQTT message volume
- Different scaling requirements per component
---
### Strategy 3: **Full Microservices** (Advanced)
**Approach**: Split into granular services following domain boundaries.
**Services**:
1. `camera-service` - Hardware control
2. `recording-service` - Recording orchestration
3. `streaming-service` - MJPEG/RTSP streaming
4. `mqtt-service` - Machine state monitoring
5. `auto-recording-service` - Automated recording logic
6. `api-gateway` - API & routing
7. `state-service` - Centralized state
8. `storage-service` - File management
**Pros**:
- ✅ Maximum flexibility
- ✅ Independent scaling per service
- ✅ Technology diversity (can use different languages)
- ✅ Team autonomy
**Cons**:
- ❌ Very complex
- ❌ High operational overhead
- ❌ Distributed system challenges (consistency, latency)
- ❌ Overkill for current needs
**Best For**: Large team, complex requirements, need for maximum flexibility
---
## 🏆 Recommended Approach: **Incremental Modularization**
### Phase 1: **Internal Refactoring** (Current → 3 months)
**Goal**: Improve code organization without breaking changes
1. **Separate concerns within monolith**:
```
camera/
├── hardware/ # Camera SDK operations
├── recording/ # Recording logic
├── streaming/ # Streaming logic
└── monitoring/ # Health checks
```
2. **Use dependency injection**: Pass dependencies explicitly
3. **Clear interfaces**: Define contracts between modules
4. **Document boundaries**: Mark what can/can't be changed independently
**Outcome**: Cleaner code, easier to split later if needed
---
### Phase 2: **Extract MQTT Service** (3-6 months)
**Goal**: Split out stateless, independent component
**Why MQTT first?**
- ✅ Completely stateless
- ✅ No shared state with cameras
- ✅ Easy to scale
- ✅ Lower risk (doesn't affect camera operations)
**Implementation**:
- Move `mqtt/` to separate service
- Use Redis/RabbitMQ for event pub/sub
- API Gateway queries MQTT service for status
- MQTT service publishes to event bus
**Outcome**: First microservice, validates approach
---
### Phase 3: **Evaluate Further Splitting** (6+ months)
**Decision Point**: Based on actual needs
**If scaling cameras**:
- Extract `camera-service` to run on multiple machines
- Keep recording/streaming together (they're tightly coupled)
**If high API load**:
- Keep API gateway separate
- Scale gateway independently
**If complex state management**:
- Extract `state-service` with Redis/PostgreSQL
- Services query state service instead of in-memory
---
## 🔧 Implementation Details
### Shared Infrastructure (All Strategies)
#### 1. **Event Bus** (Essential for microservices)
```
Option A: Redis Pub/Sub (lightweight)
Option B: RabbitMQ (more features)
Option C: MQTT (you already have it!)
```
#### 2. **State Management**
```
Option A: Redis (fast, in-memory)
Option B: PostgreSQL (persistent, queryable)
Option C: Keep in-memory for now (simplest)
```
#### 3. **Service Discovery**
```
For microservices:
- Docker Compose service names (simple)
- Consul/Eureka (if needed)
- Kubernetes services (if migrating)
```
#### 4. **API Gateway Pattern**
```
nginx/Envoy: Route requests to services
FastAPI Gateway: Aggregate responses
GraphQL: Alternative aggregation layer
```
---
## 📋 Decision Matrix
| Factor | Modular Monolith | Strategic Split | Full Microservices |
|--------|------------------|----------------|-------------------|
| **Complexity** | ⭐ Low | ⭐⭐ Medium | ⭐⭐⭐ High |
| **Scalability** | ⭐ Limited | ⭐⭐ Good | ⭐⭐⭐ Excellent |
| **Development Speed** | ⭐⭐⭐ Fast | ⭐⭐ Medium | ⭐ Slow |
| **Operational Overhead** | ⭐ Low | ⭐⭐ Medium | ⭐⭐⭐ High |
| **Risk** | ⭐ Low | ⭐⭐ Medium | ⭐⭐⭐ High |
| **Cost** | ⭐ Low | ⭐⭐ Medium | ⭐⭐⭐ High |
| **Current Fit** | ⭐⭐⭐ Perfect | ⭐⭐ Good | ⭐ Overkill |
---
## 💡 My Recommendation
### **Start with Strategy 1: Modular Monolith + Internal Refactoring**
**Why?**
1. ✅ Your system is **already working well**
2. ✅ RTSP + Recording work concurrently (hard problem solved)
3. ✅ No immediate scaling needs identified
4. ✅ Single team managing it
5. ✅ Lower risk, faster improvements
**What to do now:**
1. **Refactor internal structure** (Phase 1)
- Separate camera, recording, streaming modules
- Clear interfaces between modules
- Dependency injection
2. **Add event bus infrastructure** (prepare for future)
- Set up Redis for events (even if monolith)
- Publish events through Redis pub/sub
- Services can subscribe when needed
3. **Monitor & Measure** (data-driven decisions)
- Track performance metrics
- Identify bottlenecks
- Measure actual scaling needs
4. **Extract when needed** (not before)
- Only split when you have concrete problems
- Start with MQTT service (safest first split)
- Then camera-service if scaling cameras
**Red Flags for Microservices** (when you DON'T need them):
- ❌ "We might need to scale" (YAGNI - You Ain't Gonna Need It)
- ❌ "Industry best practice" (without actual need)
- ❌ "Multiple teams" (you have one team)
- ❌ "Independent deployment" (current deployment is simple)
**Green Flags for Microservices** (when you DO need them):
- ✅ Actually scaling cameras to multiple servers
- ✅ High API load requiring independent scaling
- ✅ Need to update camera logic without touching MQTT
- ✅ Multiple teams working on different components
- ✅ Need different technology stacks per service
---
## 🚀 Quick Start: Internal Refactoring Plan
### Step 1: Create Module Boundaries
```
usda_vision_system/
├── camera/
│ ├── hardware/ # Camera SDK wrapper
│ │ ├── camera_sdk.py
│ │ └── device_discovery.py
│ ├── recording/ # Recording logic
│ │ ├── recorder.py
│ │ └── video_writer.py
│ ├── streaming/ # Streaming logic
│ │ ├── mjpeg_streamer.py
│ │ └── rtsp_streamer.py
│ └── monitoring/ # Health & recovery
│ └── health_check.py
```
### Step 2: Define Interfaces
```python
# camera/domain/interfaces.py
class ICameraHardware(ABC):
@abstractmethod
def initialize() -> bool
@abstractmethod
def capture_frame() -> Frame
class IRecorder(ABC):
@abstractmethod
def start_recording(filename: str) -> bool
@abstractmethod
def stop_recording() -> bool
```
### Step 3: Dependency Injection
```python
# Instead of direct instantiation
recorder = CameraRecorder(config, state_manager, event_system)
# Use factories/interfaces
recorder = RecorderFactory.create(config, dependencies)
```
---
## 📚 References & Further Reading
- **Modular Monolith**: https://www.kamilgrzybek.com/blog/posts/modular-monolith-primer
- **Microservices Patterns**: https://microservices.io/patterns/
- **When to Use Microservices**: https://martinfowler.com/articles/microservices.html
---
## ❓ Questions to Answer
Before deciding on microservices, ask:
1. **Do you need to scale components independently?**
- If no → Monolith is fine
2. **Do different teams work on different parts?**
- If no → Monolith is fine
3. **Are there actual performance bottlenecks?**
- If no → Don't optimize prematurely
4. **Can you deploy the monolith easily?**
- If yes → Monolith might be better
5. **Do you need different tech stacks per component?**
- If no → Monolith is fine
---
**Bottom Line**: Your system is working well. Focus on **improving code quality and organization** rather than splitting prematurely. Extract services when you have **concrete, measurable problems** that require it.

249
REFACTORING_PLAN.md Normal file
View File

@@ -0,0 +1,249 @@
# Quick Start: Code Quality Refactoring Plan
## 🎯 Priority Order (Safe → Risky)
### ✅ Task 1: Extract Duplicate Code (30 min, zero risk)
**File**: Create `camera-management-api/usda_vision_system/camera/utils.py`
Move `suppress_camera_errors()` from 3 files into one shared location.
**Files to update**:
- `camera/recorder.py`
- `camera/streamer.py`
- `camera/monitor.py`
**Benefit**: Single source of truth, easier to fix bugs
---
### ✅ Task 2: Extract Constants (30 min, zero risk)
**File**: Create `camera-management-api/usda_vision_system/camera/constants.py`
Extract magic numbers:
- Timeouts (200ms, 1000ms, etc.)
- Queue sizes (5, 10, 30)
- Sleep intervals (0.1s, etc.)
- FPS defaults (10.0, 15.0, 30.0)
**Benefit**: Self-documenting, easy to tune
---
### ✅ Task 3: Split API Routes (1-2 hours, low risk)
**Create**:
- `api/routes/__init__.py`
- `api/routes/camera_routes.py`
- `api/routes/recording_routes.py`
- `api/routes/system_routes.py`
- `api/routes/mqtt_routes.py`
- `api/routes/storage_routes.py`
**Move routes** from `api/server.py` to appropriate modules.
**Benefit**: Much easier to navigate 800+ line file
---
### ✅ Task 4: Add Type Hints (ongoing, zero risk)
Add type hints as you touch code:
- Start with public methods
- Use `Optional[...]` for nullable values
- Use `Dict[str, ...]` for dictionaries
**Benefit**: Better IDE support, catch errors early
---
## 📝 Detailed Steps for Task 1 (Start Here!)
### Step 1: Create utils file
```python
# camera-management-api/usda_vision_system/camera/utils.py
"""Shared utilities for camera operations"""
import contextlib
import os
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout
os.close(devnull)
yield
finally:
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)
```
### Step 2: Update imports in recorder.py
```python
# camera-management-api/usda_vision_system/camera/recorder.py
# Remove the duplicate function
# Change import to:
from .utils import suppress_camera_errors
```
### Step 3: Repeat for streamer.py and monitor.py
### Step 4: Test
```bash
docker compose restart api
# Verify everything still works
```
**That's it!** Single source of truth for camera error suppression.
---
## 📝 Detailed Steps for Task 2 (Constants)
### Step 1: Create constants file
```python
# camera-management-api/usda_vision_system/camera/constants.py
"""Constants for camera operations"""
# Timeouts (milliseconds)
CAMERA_GET_BUFFER_TIMEOUT = 200
CAMERA_INIT_TIMEOUT = 1000
CAMERA_TEST_CAPTURE_TIMEOUT = 1000
# Frame queue sizes
MJPEG_QUEUE_MAXSIZE = 5
RTSP_QUEUE_MAXSIZE = 10
RECORDING_QUEUE_MAXSIZE = 30
# Frame rates
PREVIEW_FPS = 10.0
RTSP_FPS = 15.0
DEFAULT_VIDEO_FPS = 30.0
# Sleep intervals (seconds)
STREAMING_LOOP_SLEEP = 0.1
FRAME_RATE_CONTROL_SLEEP_BASE = 0.01
# JPEG quality
PREVIEW_JPEG_QUALITY = 70
```
### Step 2: Update files to use constants
```python
# Before
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 200)
# After
from .constants import CAMERA_GET_BUFFER_TIMEOUT
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_GET_BUFFER_TIMEOUT)
```
---
## 📝 Detailed Steps for Task 3 (Route Splitting)
### Step 1: Create routes directory structure
```
api/
├── __init__.py
├── server.py
├── models.py
└── routes/
├── __init__.py
├── camera_routes.py
├── recording_routes.py
├── system_routes.py
├── mqtt_routes.py
└── storage_routes.py
```
### Step 2: Example - camera_routes.py
```python
# api/routes/camera_routes.py
from fastapi import APIRouter, HTTPException
from typing import Dict
from ..models import CameraStatusResponse
def register_camera_routes(app, camera_manager, logger):
"""Register camera-related routes"""
@app.get("/cameras", response_model=Dict[str, CameraStatusResponse])
async def get_cameras():
"""Get all camera statuses"""
try:
return camera_manager.get_all_camera_status()
except Exception as e:
logger.error(f"Error getting cameras: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse)
async def get_camera_status(camera_name: str):
"""Get specific camera status"""
# ... implementation
```
### Step 3: Update server.py
```python
# api/server.py
def _setup_routes(self):
from .routes import camera_routes, recording_routes, system_routes
# Register route groups
camera_routes.register_camera_routes(self.app, self.camera_manager, self.logger)
recording_routes.register_recording_routes(self.app, self.camera_manager, self.logger)
system_routes.register_system_routes(self.app, self.state_manager, self.logger)
# ... etc
```
---
## ✅ Testing After Each Refactoring
```bash
# 1. Restart API
docker compose restart api
# 2. Test key endpoints
curl http://localhost:8000/health
curl http://localhost:8000/system/status
curl http://localhost:8000/cameras
# 3. Test camera operations (if cameras connected)
curl http://localhost:8000/cameras/camera1/status
# 4. Check logs
docker compose logs api --tail 50
```
---
## 📊 Progress Tracking
- [ ] Task 1: Extract duplicate code (utils.py)
- [ ] Task 2: Extract constants (constants.py)
- [ ] Task 3: Split API routes (routes/ directory)
- [ ] Task 4: Add type hints (ongoing)
**Estimated Time**: 2-3 hours total for first 3 tasks
**Risk Level**: Very Low - All are structural changes with no behavior modification
---
**Start with Task 1 - it's the easiest and gives immediate benefit!**

View File

@@ -0,0 +1,22 @@
"""
API route modules.
"""
from .system_routes import register_system_routes
from .camera_routes import register_camera_routes
from .recording_routes import register_recording_routes
from .mqtt_routes import register_mqtt_routes
from .storage_routes import register_storage_routes
from .auto_recording_routes import register_auto_recording_routes
from .recordings_routes import register_recordings_routes
__all__ = [
"register_system_routes",
"register_camera_routes",
"register_recording_routes",
"register_mqtt_routes",
"register_storage_routes",
"register_auto_recording_routes",
"register_recordings_routes",
]

View File

@@ -0,0 +1,100 @@
"""
Auto-recording configuration API routes.
"""
import logging
from typing import Optional
from fastapi import FastAPI, HTTPException
from ...core.config import Config
from ...core.state_manager import StateManager
from ...recording.auto_manager import AutoRecordingManager
from ..models import AutoRecordingConfigResponse, AutoRecordingStatusResponse
def register_auto_recording_routes(
app: FastAPI,
config: Config,
state_manager: StateManager,
auto_recording_manager: Optional[AutoRecordingManager],
logger: logging.Logger
):
"""Register auto-recording configuration routes"""
@app.post("/cameras/{camera_name}/auto-recording/enable", response_model=AutoRecordingConfigResponse)
async def enable_auto_recording(camera_name: str):
"""Enable auto-recording for a camera"""
try:
if not auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
# Update camera configuration
camera_config = config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
camera_config.auto_start_recording_enabled = True
config.save_config()
# Update camera status in state manager
camera_info = state_manager.get_camera_status(camera_name)
if camera_info:
camera_info.auto_recording_enabled = True
return AutoRecordingConfigResponse(
success=True,
message=f"Auto-recording enabled for camera {camera_name}",
camera_name=camera_name,
enabled=True
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error enabling auto-recording for camera {camera_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/auto-recording/disable", response_model=AutoRecordingConfigResponse)
async def disable_auto_recording(camera_name: str):
"""Disable auto-recording for a camera"""
try:
if not auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
# Update camera configuration
camera_config = config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
camera_config.auto_start_recording_enabled = False
config.save_config()
# Update camera status in state manager
camera_info = state_manager.get_camera_status(camera_name)
if camera_info:
camera_info.auto_recording_enabled = False
camera_info.auto_recording_active = False
return AutoRecordingConfigResponse(
success=True,
message=f"Auto-recording disabled for camera {camera_name}",
camera_name=camera_name,
enabled=False
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error disabling auto-recording for camera {camera_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/auto-recording/status", response_model=AutoRecordingStatusResponse)
async def get_auto_recording_status():
"""Get auto-recording manager status"""
try:
if not auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
status = auto_recording_manager.get_status()
return AutoRecordingStatusResponse(**status)
except Exception as e:
logger.error(f"Error getting auto-recording status: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,489 @@
"""
Camera-related API routes.
"""
import logging
import os
from typing import Dict
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from ...core.config import Config
from ...core.state_manager import StateManager
from ...camera.manager import CameraManager
from ..models import (
CameraStatusResponse,
CameraTestResponse,
CameraConfigResponse,
CameraConfigRequest,
CameraRecoveryResponse,
MachineStatusResponse,
)
def register_camera_routes(
app: FastAPI,
config: Config,
state_manager: StateManager,
camera_manager: CameraManager,
logger: logging.Logger
):
"""Register camera-related routes"""
@app.get("/machines", response_model=Dict[str, MachineStatusResponse])
async def get_machines():
"""Get all machine statuses"""
try:
machines = 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:
logger.error(f"Error getting machines: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/cameras", response_model=Dict[str, CameraStatusResponse])
async def get_cameras():
"""Get all camera statuses"""
try:
cameras = state_manager.get_all_cameras()
return {
name: CameraStatusResponse(
name=camera.name,
status=camera.status.value,
is_recording=camera.is_recording,
last_checked=camera.last_checked.isoformat(),
last_error=camera.last_error,
device_info=camera.device_info,
current_recording_file=camera.current_recording_file,
recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None,
auto_recording_enabled=camera.auto_recording_enabled,
auto_recording_active=camera.auto_recording_active,
auto_recording_failure_count=camera.auto_recording_failure_count,
auto_recording_last_attempt=camera.auto_recording_last_attempt.isoformat() if camera.auto_recording_last_attempt else None,
auto_recording_last_error=camera.auto_recording_last_error,
)
for name, camera in cameras.items()
}
except Exception as e:
logger.error(f"Error getting cameras: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse)
async def get_camera_status(camera_name: str):
"""Get specific camera status"""
try:
camera = 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:
logger.error(f"Error getting camera status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/test-connection", response_model=CameraTestResponse)
async def test_camera_connection(camera_name: str):
"""Test camera connection"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.test_camera_connection(camera_name)
if success:
return CameraTestResponse(
success=True,
message=f"Camera {camera_name} connection test passed",
camera_name=camera_name
)
else:
return CameraTestResponse(
success=False,
message=f"Camera {camera_name} connection test failed",
camera_name=camera_name
)
except Exception as e:
logger.error(f"Error testing camera connection: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/cameras/{camera_name}/stream")
async def camera_stream(camera_name: str):
"""Get live MJPEG stream from camera"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
# Get camera streamer
streamer = camera_manager.get_camera_streamer(camera_name)
if not streamer:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
# Start streaming if not already active
if not streamer.is_streaming():
success = streamer.start_streaming()
if not success:
raise HTTPException(status_code=500, detail=f"Failed to start streaming for camera {camera_name}")
# Return MJPEG stream
return StreamingResponse(streamer.get_frame_generator(), media_type="multipart/x-mixed-replace; boundary=frame")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error starting camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/start-stream")
async def start_camera_stream(camera_name: str):
"""Start streaming for a camera"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.start_camera_streaming(camera_name)
if success:
return {"success": True, "message": f"Started streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to start streaming for camera {camera_name}"}
except Exception as e:
logger.error(f"Error starting camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/stop-stream")
async def stop_camera_stream(camera_name: str):
"""Stop streaming for a camera"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.stop_camera_streaming(camera_name)
if success:
return {"success": True, "message": f"Stopped streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to stop streaming for camera {camera_name}"}
except Exception as e:
logger.error(f"Error stopping camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/start-rtsp")
async def start_camera_rtsp_stream(camera_name: str):
"""Start RTSP streaming for a camera to MediaMTX"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.start_camera_rtsp_streaming(camera_name)
if success:
rtsp_url = f"rtsp://{os.getenv('MEDIAMTX_HOST', 'localhost')}:{os.getenv('MEDIAMTX_RTSP_PORT', '8554')}/{camera_name}"
return {
"success": True,
"message": f"Started RTSP streaming for camera {camera_name}",
"rtsp_url": rtsp_url
}
else:
return {"success": False, "message": f"Failed to start RTSP streaming for camera {camera_name}"}
except Exception as e:
logger.error(f"Error starting RTSP stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/stop-rtsp")
async def stop_camera_rtsp_stream(camera_name: str):
"""Stop RTSP streaming for a camera"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.stop_camera_rtsp_streaming(camera_name)
if success:
return {"success": True, "message": f"Stopped RTSP streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to stop RTSP streaming for camera {camera_name}"}
except Exception as e:
logger.error(f"Error stopping RTSP stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/cameras/{camera_name}/config", response_model=CameraConfigResponse)
async def get_camera_config(camera_name: str):
"""Get camera configuration"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
camera_config = config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
return CameraConfigResponse(
name=camera_config.name,
machine_topic=camera_config.machine_topic,
storage_path=camera_config.storage_path,
enabled=camera_config.enabled,
# Auto-recording settings
auto_start_recording_enabled=camera_config.auto_start_recording_enabled,
auto_recording_max_retries=camera_config.auto_recording_max_retries,
auto_recording_retry_delay_seconds=camera_config.auto_recording_retry_delay_seconds,
# Basic settings
exposure_ms=camera_config.exposure_ms,
gain=camera_config.gain,
target_fps=camera_config.target_fps,
# Video recording settings
video_format=camera_config.video_format,
video_codec=camera_config.video_codec,
video_quality=camera_config.video_quality,
# Image Quality Settings
sharpness=camera_config.sharpness,
contrast=camera_config.contrast,
saturation=camera_config.saturation,
gamma=camera_config.gamma,
# Noise Reduction
noise_filter_enabled=camera_config.noise_filter_enabled,
denoise_3d_enabled=camera_config.denoise_3d_enabled,
# Color Settings
auto_white_balance=camera_config.auto_white_balance,
color_temperature_preset=camera_config.color_temperature_preset,
# Manual White Balance RGB Gains
wb_red_gain=camera_config.wb_red_gain,
wb_green_gain=camera_config.wb_green_gain,
wb_blue_gain=camera_config.wb_blue_gain,
# Advanced Settings
anti_flicker_enabled=camera_config.anti_flicker_enabled,
light_frequency=camera_config.light_frequency,
bit_depth=camera_config.bit_depth,
# HDR Settings
hdr_enabled=camera_config.hdr_enabled,
hdr_gain_mode=camera_config.hdr_gain_mode,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.put("/cameras/{camera_name}/config")
async def update_camera_config(camera_name: str, request: CameraConfigRequest):
"""Update camera configuration"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
# Convert request to dict, excluding None values
config_updates = {k: v for k, v in request.dict().items() if v is not None}
if not config_updates:
raise HTTPException(status_code=400, detail="No configuration updates provided")
success = camera_manager.update_camera_config(camera_name, **config_updates)
if success:
return {
"success": True,
"message": f"Camera {camera_name} configuration updated",
"updated_settings": list(config_updates.keys())
}
else:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found or update failed")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/apply-config")
async def apply_camera_config(camera_name: str):
"""Apply current configuration to active camera (requires camera restart)"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.apply_camera_config(camera_name)
if success:
return {"success": True, "message": f"Configuration applied to camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to apply configuration to camera {camera_name}"}
except Exception as e:
logger.error(f"Error applying camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/reconnect", response_model=CameraRecoveryResponse)
async def reconnect_camera(camera_name: str):
"""Reconnect to a camera"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.reconnect_camera(camera_name)
if success:
return CameraRecoveryResponse(
success=True,
message=f"Camera {camera_name} reconnected successfully",
camera_name=camera_name,
operation="reconnect"
)
else:
return CameraRecoveryResponse(
success=False,
message=f"Failed to reconnect camera {camera_name}",
camera_name=camera_name,
operation="reconnect"
)
except Exception as e:
logger.error(f"Error reconnecting camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/restart-grab", response_model=CameraRecoveryResponse)
async def restart_camera_grab(camera_name: str):
"""Restart camera grab process"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.restart_camera_grab(camera_name)
if success:
return CameraRecoveryResponse(
success=True,
message=f"Camera {camera_name} grab process restarted successfully",
camera_name=camera_name,
operation="restart-grab"
)
else:
return CameraRecoveryResponse(
success=False,
message=f"Failed to restart grab process for camera {camera_name}",
camera_name=camera_name,
operation="restart-grab"
)
except Exception as e:
logger.error(f"Error restarting camera grab: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/reset-timestamp", response_model=CameraRecoveryResponse)
async def reset_camera_timestamp(camera_name: str):
"""Reset camera timestamp"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.reset_camera_timestamp(camera_name)
if success:
return CameraRecoveryResponse(
success=True,
message=f"Camera {camera_name} timestamp reset successfully",
camera_name=camera_name,
operation="reset-timestamp"
)
else:
return CameraRecoveryResponse(
success=False,
message=f"Failed to reset timestamp for camera {camera_name}",
camera_name=camera_name,
operation="reset-timestamp"
)
except Exception as e:
logger.error(f"Error resetting camera timestamp: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/full-reset", response_model=CameraRecoveryResponse)
async def full_reset_camera(camera_name: str):
"""Perform full camera reset (uninitialize and reinitialize)"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.full_reset_camera(camera_name)
if success:
return CameraRecoveryResponse(
success=True,
message=f"Camera {camera_name} full reset completed successfully",
camera_name=camera_name,
operation="full-reset"
)
else:
return CameraRecoveryResponse(
success=False,
message=f"Failed to perform full reset for camera {camera_name}",
camera_name=camera_name,
operation="full-reset"
)
except Exception as e:
logger.error(f"Error performing full camera reset: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/cameras/{camera_name}/reinitialize", response_model=CameraRecoveryResponse)
async def reinitialize_camera(camera_name: str):
"""Reinitialize a failed camera"""
try:
if not camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.reinitialize_failed_camera(camera_name)
if success:
return CameraRecoveryResponse(
success=True,
message=f"Camera {camera_name} reinitialized successfully",
camera_name=camera_name,
operation="reinitialize"
)
else:
return CameraRecoveryResponse(
success=False,
message=f"Failed to reinitialize camera {camera_name}",
camera_name=camera_name,
operation="reinitialize"
)
except Exception as e:
logger.error(f"Error reinitializing camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/debug/camera-manager")
async def debug_camera_manager():
"""Debug endpoint to check camera manager state"""
try:
if not camera_manager:
return {"error": "Camera manager not available"}
return {
"available_cameras": len(camera_manager.available_cameras),
"camera_recorders": list(camera_manager.camera_recorders.keys()),
"camera_streamers": list(camera_manager.camera_streamers.keys()),
"streamer_states": {
name: {
"exists": streamer is not None,
"is_streaming": streamer.is_streaming() if streamer else False,
"streaming": getattr(streamer, 'streaming', False) if streamer else False
}
for name, streamer in camera_manager.camera_streamers.items()
}
}
except Exception as e:
return {"error": str(e)}

View File

@@ -0,0 +1,72 @@
"""
MQTT-related API routes.
"""
import logging
from typing import Dict
from fastapi import FastAPI, HTTPException, Query
from ...core.state_manager import StateManager
from ...mqtt.client import MQTTClient
from ..models import MQTTStatusResponse, MQTTEventsHistoryResponse, MQTTEventResponse
def register_mqtt_routes(
app: FastAPI,
mqtt_client: MQTTClient,
state_manager: StateManager,
logger: logging.Logger
):
"""Register MQTT-related routes"""
@app.get("/mqtt/status", response_model=MQTTStatusResponse)
async def get_mqtt_status():
"""Get MQTT client status and statistics"""
try:
status = mqtt_client.get_status()
return MQTTStatusResponse(
connected=status["connected"],
broker_host=status["broker_host"],
broker_port=status["broker_port"],
subscribed_topics=status["subscribed_topics"],
last_message_time=status["last_message_time"],
message_count=status["message_count"],
error_count=status["error_count"],
uptime_seconds=status["uptime_seconds"]
)
except Exception as e:
logger.error(f"Error getting MQTT status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/mqtt/events", response_model=MQTTEventsHistoryResponse)
async def get_mqtt_events(
limit: int = Query(default=5, ge=1, le=50, description="Number of recent events to retrieve")
):
"""Get recent MQTT events history"""
try:
events = state_manager.get_recent_mqtt_events(limit)
total_events = state_manager.get_mqtt_event_count()
# Convert events to response format
event_responses = [
MQTTEventResponse(
machine_name=event.machine_name,
topic=event.topic,
payload=event.payload,
normalized_state=event.normalized_state,
timestamp=event.timestamp.isoformat(),
message_number=event.message_number
)
for event in events
]
last_updated = events[0].timestamp.isoformat() if events else None
return MQTTEventsHistoryResponse(
events=event_responses,
total_events=total_events,
last_updated=last_updated
)
except Exception as e:
logger.error(f"Error getting MQTT events: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,77 @@
"""
Recording-related API routes.
"""
import logging
from fastapi import FastAPI, HTTPException
from ...camera.manager import CameraManager
from ..models import StartRecordingResponse, StopRecordingResponse, StartRecordingRequest
from ...core.timezone_utils import format_filename_timestamp
def register_recording_routes(
app: FastAPI,
camera_manager: CameraManager,
logger: logging.Logger
):
"""Register recording-related routes"""
@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 camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = camera_manager.manual_start_recording(
camera_name=camera_name,
filename=request.filename,
exposure_ms=request.exposure_ms,
gain=request.gain,
fps=request.fps
)
if success:
# Get the actual filename that was used (with datetime prefix)
actual_filename = request.filename
if request.filename:
timestamp = format_filename_timestamp()
actual_filename = f"{timestamp}_{request.filename}"
return StartRecordingResponse(
success=True,
message=f"Recording started for {camera_name}",
filename=actual_filename
)
else:
return StartRecordingResponse(
success=False,
message=f"Failed to start recording for {camera_name}"
)
except Exception as e:
logger.error(f"Error starting recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@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 camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = 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:
logger.error(f"Error stopping recording: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,41 @@
"""
Recording session listing API routes.
"""
import logging
from typing import Dict
from fastapi import FastAPI, HTTPException
from ...core.state_manager import StateManager
from ..models import RecordingInfoResponse
def register_recordings_routes(
app: FastAPI,
state_manager: StateManager,
logger: logging.Logger
):
"""Register recordings listing routes"""
@app.get("/recordings", response_model=Dict[str, RecordingInfoResponse])
async def get_recordings():
"""Get all recording sessions"""
try:
recordings = 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:
logger.error(f"Error getting recordings: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,62 @@
"""
Storage-related API routes.
"""
import logging
from datetime import datetime
from fastapi import FastAPI, HTTPException
from ...storage.manager import StorageManager
from ..models import StorageStatsResponse, FileListResponse, CleanupResponse, FileListRequest, CleanupRequest
def register_storage_routes(
app: FastAPI,
storage_manager: StorageManager,
logger: logging.Logger
):
"""Register storage-related routes"""
@app.get("/storage/stats", response_model=StorageStatsResponse)
async def get_storage_stats():
"""Get storage statistics"""
try:
stats = storage_manager.get_storage_statistics()
return StorageStatsResponse(**stats)
except Exception as e:
logger.error(f"Error getting storage stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@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 = 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:
logger.error(f"Error getting files: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/storage/cleanup", response_model=CleanupResponse)
async def cleanup_storage(request: CleanupRequest):
"""Clean up old storage files"""
try:
result = storage_manager.cleanup_old_files(request.max_age_days)
return CleanupResponse(**result)
except Exception as e:
logger.error(f"Error during cleanup: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,67 @@
"""
System-related API routes.
"""
import logging
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, HTTPException
from ...core.config import Config
from ...core.state_manager import StateManager
from ...video.integration import VideoModule
from ..models import SuccessResponse, SystemStatusResponse
def register_system_routes(
app: FastAPI,
state_manager: StateManager,
video_module: Optional[VideoModule],
server_start_time: datetime,
logger: logging.Logger
):
"""Register system-related routes"""
@app.get("/", response_model=SuccessResponse)
async def root():
return SuccessResponse(message="USDA Vision Camera System API")
@app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
@app.get("/system/status", response_model=SystemStatusResponse)
async def get_system_status():
"""Get overall system status"""
try:
summary = state_manager.get_system_summary()
uptime = (datetime.now() - 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:
logger.error(f"Error getting system status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/system/video-module")
async def get_video_module_status():
"""Get video module status and configuration"""
try:
if video_module:
status = video_module.get_module_status()
status["enabled"] = True
return status
else:
return {"enabled": False, "error": "Video module not initialized"}
except Exception as e:
logger.error(f"Error getting video module status: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -23,6 +23,15 @@ from ..core.events import EventSystem, EventType, Event
from ..storage.manager import StorageManager
from ..video.integration import create_video_module, VideoModule
from .models import *
from .routes import (
register_system_routes,
register_camera_routes,
register_recording_routes,
register_mqtt_routes,
register_storage_routes,
register_auto_recording_routes,
register_recordings_routes,
)
class WebSocketManager:
@@ -114,594 +123,58 @@ class APIServer:
def _setup_routes(self):
"""Setup API routes"""
@self.app.get("/", response_model=SuccessResponse)
async def root():
return SuccessResponse(message="USDA Vision Camera System API")
@self.app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
@self.app.get("/system/status", response_model=SystemStatusResponse)
async def get_system_status():
"""Get overall system status"""
try:
summary = self.state_manager.get_system_summary()
uptime = (datetime.now() - self.server_start_time).total_seconds()
return SystemStatusResponse(system_started=summary["system_started"], mqtt_connected=summary["mqtt_connected"], last_mqtt_message=summary["last_mqtt_message"], machines=summary["machines"], cameras=summary["cameras"], active_recordings=summary["active_recordings"], total_recordings=summary["total_recordings"], uptime_seconds=uptime)
except Exception as e:
self.logger.error(f"Error getting system status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/system/video-module")
async def get_video_module_status():
"""Get video module status and configuration"""
try:
if self.video_module:
status = self.video_module.get_module_status()
status["enabled"] = True
return status
else:
return {"enabled": False, "error": "Video module not initialized"}
except Exception as e:
self.logger.error(f"Error getting video module status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/machines", response_model=Dict[str, MachineStatusResponse])
async def get_machines():
"""Get all machine statuses"""
try:
machines = self.state_manager.get_all_machines()
return {name: MachineStatusResponse(name=machine.name, state=machine.state.value, last_updated=machine.last_updated.isoformat(), last_message=machine.last_message, mqtt_topic=machine.mqtt_topic) for name, machine in machines.items()}
except Exception as e:
self.logger.error(f"Error getting machines: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/mqtt/status", response_model=MQTTStatusResponse)
async def get_mqtt_status():
"""Get MQTT client status and statistics"""
try:
status = self.mqtt_client.get_status()
return MQTTStatusResponse(connected=status["connected"], broker_host=status["broker_host"], broker_port=status["broker_port"], subscribed_topics=status["subscribed_topics"], last_message_time=status["last_message_time"], message_count=status["message_count"], error_count=status["error_count"], uptime_seconds=status["uptime_seconds"])
except Exception as e:
self.logger.error(f"Error getting MQTT status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/mqtt/events", response_model=MQTTEventsHistoryResponse)
async def get_mqtt_events(limit: int = Query(default=5, ge=1, le=50, description="Number of recent events to retrieve")):
"""Get recent MQTT events history"""
try:
events = self.state_manager.get_recent_mqtt_events(limit)
total_events = self.state_manager.get_mqtt_event_count()
# Convert events to response format
event_responses = [MQTTEventResponse(machine_name=event.machine_name, topic=event.topic, payload=event.payload, normalized_state=event.normalized_state, timestamp=event.timestamp.isoformat(), message_number=event.message_number) for event in events]
last_updated = events[0].timestamp.isoformat() if events else None
return MQTTEventsHistoryResponse(events=event_responses, total_events=total_events, last_updated=last_updated)
except Exception as e:
self.logger.error(f"Error getting MQTT events: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras", response_model=Dict[str, CameraStatusResponse])
async def get_cameras():
"""Get all camera statuses"""
try:
cameras = self.state_manager.get_all_cameras()
return {
name: CameraStatusResponse(
name=camera.name,
status=camera.status.value,
is_recording=camera.is_recording,
last_checked=camera.last_checked.isoformat(),
last_error=camera.last_error,
device_info=camera.device_info,
current_recording_file=camera.current_recording_file,
recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None,
auto_recording_enabled=camera.auto_recording_enabled,
auto_recording_active=camera.auto_recording_active,
auto_recording_failure_count=camera.auto_recording_failure_count,
auto_recording_last_attempt=camera.auto_recording_last_attempt.isoformat() if camera.auto_recording_last_attempt else None,
auto_recording_last_error=camera.auto_recording_last_error,
)
for name, camera in cameras.items()
}
except Exception as e:
self.logger.error(f"Error getting cameras: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse)
async def get_camera_status(camera_name: str):
"""Get specific camera status"""
try:
camera = self.state_manager.get_camera_status(camera_name)
if not camera:
raise HTTPException(status_code=404, detail=f"Camera not found: {camera_name}")
return CameraStatusResponse(name=camera.name, status=camera.status.value, is_recording=camera.is_recording, last_checked=camera.last_checked.isoformat(), last_error=camera.last_error, device_info=camera.device_info, current_recording_file=camera.current_recording_file, recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error getting camera status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse)
async def start_recording(camera_name: str, request: StartRecordingRequest):
"""Manually start recording for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_start_recording(camera_name=camera_name, filename=request.filename, exposure_ms=request.exposure_ms, gain=request.gain, fps=request.fps)
if success:
# Get the actual filename that was used (with datetime prefix)
actual_filename = request.filename
if request.filename:
from ..core.timezone_utils import format_filename_timestamp
timestamp = format_filename_timestamp()
actual_filename = f"{timestamp}_{request.filename}"
return StartRecordingResponse(success=True, message=f"Recording started for {camera_name}", filename=actual_filename)
else:
return StartRecordingResponse(success=False, message=f"Failed to start recording for {camera_name}")
except Exception as e:
self.logger.error(f"Error starting recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse)
async def stop_recording(camera_name: str):
"""Manually stop recording for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_stop_recording(camera_name)
if success:
return StopRecordingResponse(success=True, message=f"Recording stopped for {camera_name}")
else:
return StopRecordingResponse(success=False, message=f"Failed to stop recording for {camera_name}")
except Exception as e:
self.logger.error(f"Error stopping recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/test-connection", response_model=CameraTestResponse)
async def test_camera_connection(camera_name: str):
"""Test camera connection"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.test_camera_connection(camera_name)
if success:
return CameraTestResponse(success=True, message=f"Camera {camera_name} connection test passed", camera_name=camera_name)
else:
return CameraTestResponse(success=False, message=f"Camera {camera_name} connection test failed", camera_name=camera_name)
except Exception as e:
self.logger.error(f"Error testing camera connection: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/stream")
async def camera_stream(camera_name: str):
"""Get live MJPEG stream from camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
# Get camera streamer
streamer = self.camera_manager.get_camera_streamer(camera_name)
if not streamer:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
# Start streaming if not already active
if not streamer.is_streaming():
success = streamer.start_streaming()
if not success:
raise HTTPException(status_code=500, detail=f"Failed to start streaming for camera {camera_name}")
# Return MJPEG stream
return StreamingResponse(streamer.get_frame_generator(), media_type="multipart/x-mixed-replace; boundary=frame")
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error starting camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-stream")
async def start_camera_stream(camera_name: str):
"""Start streaming for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.start_camera_streaming(camera_name)
if success:
return {"success": True, "message": f"Started streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to start streaming for camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error starting camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-stream")
async def stop_camera_stream(camera_name: str):
"""Stop streaming for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.stop_camera_streaming(camera_name)
if success:
return {"success": True, "message": f"Stopped streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to stop streaming for camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error stopping camera stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-rtsp")
async def start_camera_rtsp_stream(camera_name: str):
"""Start RTSP streaming for a camera to MediaMTX"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.start_camera_rtsp_streaming(camera_name)
if success:
rtsp_url = f"rtsp://{os.getenv('MEDIAMTX_HOST', 'localhost')}:{os.getenv('MEDIAMTX_RTSP_PORT', '8554')}/{camera_name}"
return {
"success": True,
"message": f"Started RTSP streaming for camera {camera_name}",
"rtsp_url": rtsp_url
}
else:
return {"success": False, "message": f"Failed to start RTSP streaming for camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error starting RTSP stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-rtsp")
async def stop_camera_rtsp_stream(camera_name: str):
"""Stop RTSP streaming for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.stop_camera_rtsp_streaming(camera_name)
if success:
return {"success": True, "message": f"Stopped RTSP streaming for camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to stop RTSP streaming for camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error stopping RTSP stream: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/config", response_model=CameraConfigResponse)
async def get_camera_config(camera_name: str):
"""Get camera configuration"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
config = self.camera_manager.get_camera_config(camera_name)
if not config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
return CameraConfigResponse(
name=config.name,
machine_topic=config.machine_topic,
storage_path=config.storage_path,
enabled=config.enabled,
# Auto-recording settings
auto_start_recording_enabled=config.auto_start_recording_enabled,
auto_recording_max_retries=config.auto_recording_max_retries,
auto_recording_retry_delay_seconds=config.auto_recording_retry_delay_seconds,
# Basic settings
exposure_ms=config.exposure_ms,
gain=config.gain,
target_fps=config.target_fps,
# Video recording settings
video_format=config.video_format,
video_codec=config.video_codec,
video_quality=config.video_quality,
# Image Quality Settings
sharpness=config.sharpness,
contrast=config.contrast,
saturation=config.saturation,
gamma=config.gamma,
# Noise Reduction
noise_filter_enabled=config.noise_filter_enabled,
denoise_3d_enabled=config.denoise_3d_enabled,
# Color Settings
auto_white_balance=config.auto_white_balance,
color_temperature_preset=config.color_temperature_preset,
# Manual White Balance RGB Gains
wb_red_gain=config.wb_red_gain,
wb_green_gain=config.wb_green_gain,
wb_blue_gain=config.wb_blue_gain,
# Advanced Settings
anti_flicker_enabled=config.anti_flicker_enabled,
light_frequency=config.light_frequency,
bit_depth=config.bit_depth,
# HDR Settings
hdr_enabled=config.hdr_enabled,
hdr_gain_mode=config.hdr_gain_mode,
)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error getting camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.put("/cameras/{camera_name}/config")
async def update_camera_config(camera_name: str, request: CameraConfigRequest):
"""Update camera configuration"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
# Convert request to dict, excluding None values
config_updates = {k: v for k, v in request.dict().items() if v is not None}
if not config_updates:
raise HTTPException(status_code=400, detail="No configuration updates provided")
success = self.camera_manager.update_camera_config(camera_name, **config_updates)
if success:
return {"success": True, "message": f"Camera {camera_name} configuration updated", "updated_settings": list(config_updates.keys())}
else:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found or update failed")
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error updating camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/apply-config")
async def apply_camera_config(camera_name: str):
"""Apply current configuration to active camera (requires camera restart)"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.apply_camera_config(camera_name)
if success:
return {"success": True, "message": f"Configuration applied to camera {camera_name}"}
else:
return {"success": False, "message": f"Failed to apply configuration to camera {camera_name}"}
except Exception as e:
self.logger.error(f"Error applying camera config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reconnect", response_model=CameraRecoveryResponse)
async def reconnect_camera(camera_name: str):
"""Reconnect to a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reconnect_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reconnected successfully", camera_name=camera_name, operation="reconnect")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reconnect camera {camera_name}", camera_name=camera_name, operation="reconnect")
except Exception as e:
self.logger.error(f"Error reconnecting camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/restart-grab", response_model=CameraRecoveryResponse)
async def restart_camera_grab(camera_name: str):
"""Restart camera grab process"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.restart_camera_grab(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} grab process restarted successfully", camera_name=camera_name, operation="restart-grab")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to restart grab process for camera {camera_name}", camera_name=camera_name, operation="restart-grab")
except Exception as e:
self.logger.error(f"Error restarting camera grab: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reset-timestamp", response_model=CameraRecoveryResponse)
async def reset_camera_timestamp(camera_name: str):
"""Reset camera timestamp"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reset_camera_timestamp(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} timestamp reset successfully", camera_name=camera_name, operation="reset-timestamp")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reset timestamp for camera {camera_name}", camera_name=camera_name, operation="reset-timestamp")
except Exception as e:
self.logger.error(f"Error resetting camera timestamp: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/full-reset", response_model=CameraRecoveryResponse)
async def full_reset_camera(camera_name: str):
"""Perform full camera reset (uninitialize and reinitialize)"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.full_reset_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} full reset completed successfully", camera_name=camera_name, operation="full-reset")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to perform full reset for camera {camera_name}", camera_name=camera_name, operation="full-reset")
except Exception as e:
self.logger.error(f"Error performing full camera reset: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reinitialize", response_model=CameraRecoveryResponse)
async def reinitialize_camera(camera_name: str):
"""Reinitialize a failed camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reinitialize_failed_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reinitialized successfully", camera_name=camera_name, operation="reinitialize")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reinitialize camera {camera_name}", camera_name=camera_name, operation="reinitialize")
except Exception as e:
self.logger.error(f"Error reinitializing camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/auto-recording/enable", response_model=AutoRecordingConfigResponse)
async def enable_auto_recording(camera_name: str):
"""Enable auto-recording for a camera"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
# Update camera configuration
camera_config = self.config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
camera_config.auto_start_recording_enabled = True
self.config.save_config()
# Update camera status in state manager
camera_info = self.state_manager.get_camera_status(camera_name)
if camera_info:
camera_info.auto_recording_enabled = True
return AutoRecordingConfigResponse(success=True, message=f"Auto-recording enabled for camera {camera_name}", camera_name=camera_name, enabled=True)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error enabling auto-recording for camera {camera_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/auto-recording/disable", response_model=AutoRecordingConfigResponse)
async def disable_auto_recording(camera_name: str):
"""Disable auto-recording for a camera"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
# Update camera configuration
camera_config = self.config.get_camera_by_name(camera_name)
if not camera_config:
raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found")
camera_config.auto_start_recording_enabled = False
self.config.save_config()
# Update camera status in state manager
camera_info = self.state_manager.get_camera_status(camera_name)
if camera_info:
camera_info.auto_recording_enabled = False
camera_info.auto_recording_active = False
return AutoRecordingConfigResponse(success=True, message=f"Auto-recording disabled for camera {camera_name}", camera_name=camera_name, enabled=False)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error disabling auto-recording for camera {camera_name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/auto-recording/status", response_model=AutoRecordingStatusResponse)
async def get_auto_recording_status():
"""Get auto-recording manager status"""
try:
if not self.auto_recording_manager:
raise HTTPException(status_code=503, detail="Auto-recording manager not available")
status = self.auto_recording_manager.get_status()
return AutoRecordingStatusResponse(**status)
except Exception as e:
self.logger.error(f"Error getting auto-recording status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/recordings", response_model=Dict[str, RecordingInfoResponse])
async def get_recordings():
"""Get all recording sessions"""
try:
recordings = self.state_manager.get_all_recordings()
return {
rid: RecordingInfoResponse(
camera_name=recording.camera_name,
filename=recording.filename,
start_time=recording.start_time.isoformat(),
state=recording.state.value,
end_time=recording.end_time.isoformat() if recording.end_time else None,
file_size_bytes=recording.file_size_bytes,
frame_count=recording.frame_count,
duration_seconds=(recording.end_time - recording.start_time).total_seconds() if recording.end_time else None,
error_message=recording.error_message,
)
for rid, recording in recordings.items()
}
except Exception as e:
self.logger.error(f"Error getting recordings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/storage/stats", response_model=StorageStatsResponse)
async def get_storage_stats():
"""Get storage statistics"""
try:
stats = self.storage_manager.get_storage_statistics()
return StorageStatsResponse(**stats)
except Exception as e:
self.logger.error(f"Error getting storage stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/files", response_model=FileListResponse)
async def get_files(request: FileListRequest):
"""Get list of recording files"""
try:
start_date = None
end_date = None
if request.start_date:
start_date = datetime.fromisoformat(request.start_date)
if request.end_date:
end_date = datetime.fromisoformat(request.end_date)
files = self.storage_manager.get_recording_files(camera_name=request.camera_name, start_date=start_date, end_date=end_date, limit=request.limit)
return FileListResponse(files=files, total_count=len(files))
except Exception as e:
self.logger.error(f"Error getting files: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/cleanup", response_model=CleanupResponse)
async def cleanup_storage(request: CleanupRequest):
"""Clean up old storage files"""
try:
result = self.storage_manager.cleanup_old_files(request.max_age_days)
return CleanupResponse(**result)
except Exception as e:
self.logger.error(f"Error during cleanup: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Register routes from modules
register_system_routes(
app=self.app,
state_manager=self.state_manager,
video_module=self.video_module,
server_start_time=self.server_start_time,
logger=self.logger
)
register_camera_routes(
app=self.app,
config=self.config,
state_manager=self.state_manager,
camera_manager=self.camera_manager,
logger=self.logger
)
register_recording_routes(
app=self.app,
camera_manager=self.camera_manager,
logger=self.logger
)
register_mqtt_routes(
app=self.app,
mqtt_client=self.mqtt_client,
state_manager=self.state_manager,
logger=self.logger
)
register_storage_routes(
app=self.app,
storage_manager=self.storage_manager,
logger=self.logger
)
register_auto_recording_routes(
app=self.app,
config=self.config,
state_manager=self.state_manager,
auto_recording_manager=self.auto_recording_manager,
logger=self.logger
)
register_recordings_routes(
app=self.app,
state_manager=self.state_manager,
logger=self.logger
)
# WebSocket endpoint (not in route modules)
@self.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time updates"""

View File

@@ -0,0 +1,30 @@
"""
Constants for camera operations.
"""
# Timeouts (milliseconds)
CAMERA_GET_BUFFER_TIMEOUT = 200 # Standard frame capture timeout
CAMERA_INIT_TIMEOUT = 1000 # Camera initialization timeout
CAMERA_TEST_CAPTURE_TIMEOUT = 1000 # Test capture timeout
CAMERA_GET_BUFFER_SHORT_TIMEOUT = 100 # Shorter timeout for quick checks
# Frame queue sizes
MJPEG_QUEUE_MAXSIZE = 5 # Buffer for latest frames (for MJPEG streaming)
RTSP_QUEUE_MAXSIZE = 10 # Buffer for RTSP frames (larger buffer for smoother streaming)
RECORDING_QUEUE_MAXSIZE = 30 # Buffer for recording frames (shared with recorder)
# Frame rates (FPS)
PREVIEW_FPS = 10.0 # Lower FPS for preview to reduce load
RTSP_FPS = 15.0 # RTSP FPS (can be higher than MJPEG preview)
DEFAULT_VIDEO_FPS = 30.0 # Default video FPS when target_fps is 0 or unspecified
# Sleep intervals (seconds)
STREAMING_LOOP_SLEEP = 0.1 # Sleep interval in streaming loops when waiting
BRIEF_PAUSE_SLEEP = 0.1 # Brief pause before retrying operations
# JPEG quality (0-100)
PREVIEW_JPEG_QUALITY = 70 # JPEG quality for streaming preview
# Video writer buffer size
VIDEO_WRITER_CHUNK_SIZE = 8192 # Buffer size for video writer operations

View File

@@ -9,7 +9,6 @@ import os
import threading
import time
import logging
import contextlib
from typing import Dict, List, Optional, Any
# Add camera SDK to path
@@ -20,30 +19,8 @@ from ..core.config import Config
from ..core.state_manager import StateManager, CameraStatus
from ..core.events import EventSystem, publish_camera_status_changed
from .sdk_config import ensure_sdk_initialized
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
# Save original file descriptors
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
# Redirect stderr and stdout to devnull
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
os.close(devnull)
yield
finally:
# Restore original file descriptors
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)
from .utils import suppress_camera_errors
from .constants import CAMERA_TEST_CAPTURE_TIMEOUT
class CameraMonitor:
@@ -219,7 +196,7 @@ class CameraMonitor:
mvsdk.CameraPlay(hCamera)
# Try to capture with short timeout
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(hCamera, 500)
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(hCamera, CAMERA_TEST_CAPTURE_TIMEOUT)
mvsdk.CameraReleaseImageBuffer(hCamera, pRawData)
# Success - camera is available

View File

@@ -11,7 +11,6 @@ import time
import logging
import cv2
import numpy as np
import contextlib
import queue
from typing import Optional, Dict, Any
from datetime import datetime
@@ -26,30 +25,7 @@ from ..core.state_manager import StateManager
from ..core.events import EventSystem, publish_recording_started, publish_recording_stopped, publish_recording_error
from ..core.timezone_utils import now_atlanta, format_filename_timestamp
from .sdk_config import ensure_sdk_initialized
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
# Save original file descriptors
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
# Redirect stderr and stdout to devnull
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
os.close(devnull)
yield
finally:
# Restore original file descriptors
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)
from .utils import suppress_camera_errors
class CameraRecorder:
@@ -537,7 +513,7 @@ class CameraRecorder:
"""Test if camera can capture frames"""
try:
# Try to capture one frame
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 1000) # 1 second timeout
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_TEST_CAPTURE_TIMEOUT)
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData)
return True
@@ -686,7 +662,7 @@ class CameraRecorder:
continue
else:
# Capture frame directly from camera
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 200) # 200ms timeout
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_GET_BUFFER_TIMEOUT)
# Process frame
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
@@ -770,7 +746,7 @@ class CameraRecorder:
self.logger.info(f"Using frame dimensions from streamer frame: {frame_size}")
elif self.hCamera:
# Get frame dimensions by capturing a test frame from camera
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 1000)
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_INIT_TIMEOUT)
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData)
frame_size = (FrameHead.iWidth, FrameHead.iHeight)
@@ -779,7 +755,7 @@ class CameraRecorder:
if self.streamer and self.streamer.hCamera:
try:
with suppress_camera_errors():
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.streamer.hCamera, 1000)
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.streamer.hCamera, CAMERA_INIT_TIMEOUT)
mvsdk.CameraReleaseImageBuffer(self.streamer.hCamera, pRawData)
frame_size = (FrameHead.iWidth, FrameHead.iHeight)
self.logger.info(f"Got frame dimensions from streamer's camera: {frame_size}")
@@ -798,8 +774,8 @@ class CameraRecorder:
# Set up video writer with configured codec
fourcc = cv2.VideoWriter_fourcc(*self.camera_config.video_codec)
# Use 30 FPS for video writer if target_fps is 0 (unlimited)
video_fps = self.camera_config.target_fps if self.camera_config.target_fps > 0 else 30.0
# Use default FPS for video writer if target_fps is 0 (unlimited)
video_fps = self.camera_config.target_fps if self.camera_config.target_fps > 0 else DEFAULT_VIDEO_FPS
# Create video writer with quality settings
self.video_writer = cv2.VideoWriter(self.output_filename, fourcc, video_fps, frame_size)
@@ -883,7 +859,7 @@ class CameraRecorder:
# Small delay to ensure file system sync
import time
time.sleep(0.1)
time.sleep(BRIEF_PAUSE_SLEEP)
# Verify file exists and has content
if self.output_filename and os.path.exists(self.output_filename):

View File

@@ -12,7 +12,6 @@ import time
import logging
import cv2
import numpy as np
import contextlib
import subprocess
from typing import Optional, Dict, Any, Generator
from datetime import datetime
@@ -26,30 +25,19 @@ from ..core.config import CameraConfig
from ..core.state_manager import StateManager
from ..core.events import EventSystem
from .sdk_config import ensure_sdk_initialized
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
# Save original file descriptors
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
# Redirect stderr and stdout to devnull
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
os.close(devnull)
yield
finally:
# Restore original file descriptors
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)
from .utils import suppress_camera_errors
from .constants import (
MJPEG_QUEUE_MAXSIZE,
RTSP_QUEUE_MAXSIZE,
RECORDING_QUEUE_MAXSIZE,
PREVIEW_FPS,
RTSP_FPS,
PREVIEW_JPEG_QUALITY,
CAMERA_GET_BUFFER_TIMEOUT,
CAMERA_TEST_CAPTURE_TIMEOUT,
STREAMING_LOOP_SLEEP,
BRIEF_PAUSE_SLEEP,
)
class CameraStreamer:
@@ -78,17 +66,17 @@ class CameraStreamer:
self._rtsp_thread: Optional[threading.Thread] = None
self._stop_streaming_event = threading.Event()
self._stop_rtsp_event = threading.Event()
self._frame_queue = queue.Queue(maxsize=5) # Buffer for latest frames (for MJPEG streaming)
self._rtsp_frame_queue = queue.Queue(maxsize=10) # Buffer for RTSP frames (larger buffer for smoother streaming)
self._recording_frame_queue = queue.Queue(maxsize=30) # Buffer for recording frames (shared with recorder)
self._frame_queue = queue.Queue(maxsize=MJPEG_QUEUE_MAXSIZE) # Buffer for latest frames (for MJPEG streaming)
self._rtsp_frame_queue = queue.Queue(maxsize=RTSP_QUEUE_MAXSIZE) # Buffer for RTSP frames (larger buffer for smoother streaming)
self._recording_frame_queue = queue.Queue(maxsize=RECORDING_QUEUE_MAXSIZE) # Buffer for recording frames (shared with recorder)
self._lock = threading.RLock()
# Stream settings (optimized for preview)
self.preview_fps = 10.0 # Lower FPS for preview to reduce load
self.preview_quality = 70 # JPEG quality for streaming
self.preview_fps = PREVIEW_FPS # Lower FPS for preview to reduce load
self.preview_quality = PREVIEW_JPEG_QUALITY # JPEG quality for streaming
# RTSP settings
self.rtsp_fps = 15.0 # RTSP FPS (can be higher than MJPEG preview)
self.rtsp_fps = RTSP_FPS # RTSP FPS (can be higher than MJPEG preview)
# Use MEDIAMTX_HOST env var if set, otherwise default to localhost
# Note: If API uses network_mode: host, MediaMTX container ports are exposed to host
# So localhost should work, but MediaMTX must be accessible on that port
@@ -254,7 +242,7 @@ class CameraStreamer:
if frame_bytes:
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame_bytes + b"\r\n")
else:
time.sleep(0.1) # Wait a bit if no frame available
time.sleep(STREAMING_LOOP_SLEEP) # Wait a bit if no frame available
def _initialize_camera(self) -> bool:
"""Initialize camera for streaming (separate from recording)"""
@@ -366,11 +354,11 @@ class CameraStreamer:
try:
# If using shared camera, skip capture - recorder will populate queues
if self._using_shared_camera:
time.sleep(0.1) # Just wait, recorder populates queues
time.sleep(STREAMING_LOOP_SLEEP) # Just wait, recorder populates queues
continue
# Capture frame with timeout
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 200) # 200ms timeout
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_GET_BUFFER_TIMEOUT)
# Process frame
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
@@ -431,7 +419,7 @@ class CameraStreamer:
except Exception as e:
if not self._stop_streaming_event.is_set():
self.logger.error(f"Error in streaming loop: {e}")
time.sleep(0.1) # Brief pause before retrying
time.sleep(BRIEF_PAUSE_SLEEP) # Brief pause before retrying
except Exception as e:
self.logger.error(f"Fatal error in streaming loop: {e}")

View File

@@ -0,0 +1,31 @@
"""
Shared utilities for camera operations.
"""
import contextlib
import os
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
# Save original file descriptors
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
# Redirect stderr and stdout to devnull
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
os.close(devnull)
yield
finally:
# Restore original file descriptors
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)