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 ..storage.manager import StorageManager
from ..video.integration import create_video_module, VideoModule from ..video.integration import create_video_module, VideoModule
from .models import * 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: class WebSocketManager:
@@ -114,594 +123,58 @@ class APIServer:
def _setup_routes(self): def _setup_routes(self):
"""Setup API routes""" """Setup API routes"""
@self.app.get("/", response_model=SuccessResponse) # Register routes from modules
async def root(): register_system_routes(
return SuccessResponse(message="USDA Vision Camera System API") app=self.app,
state_manager=self.state_manager,
@self.app.get("/health") video_module=self.video_module,
async def health_check(): server_start_time=self.server_start_time,
return {"status": "healthy", "timestamp": datetime.now().isoformat()} logger=self.logger
)
@self.app.get("/system/status", response_model=SystemStatusResponse)
async def get_system_status(): register_camera_routes(
"""Get overall system status""" app=self.app,
try: config=self.config,
summary = self.state_manager.get_system_summary() state_manager=self.state_manager,
uptime = (datetime.now() - self.server_start_time).total_seconds() camera_manager=self.camera_manager,
logger=self.logger
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}") register_recording_routes(
raise HTTPException(status_code=500, detail=str(e)) app=self.app,
camera_manager=self.camera_manager,
@self.app.get("/system/video-module") logger=self.logger
async def get_video_module_status(): )
"""Get video module status and configuration"""
try: register_mqtt_routes(
if self.video_module: app=self.app,
status = self.video_module.get_module_status() mqtt_client=self.mqtt_client,
status["enabled"] = True state_manager=self.state_manager,
return status logger=self.logger
else: )
return {"enabled": False, "error": "Video module not initialized"}
except Exception as e: register_storage_routes(
self.logger.error(f"Error getting video module status: {e}") app=self.app,
raise HTTPException(status_code=500, detail=str(e)) storage_manager=self.storage_manager,
logger=self.logger
@self.app.get("/machines", response_model=Dict[str, MachineStatusResponse]) )
async def get_machines():
"""Get all machine statuses""" register_auto_recording_routes(
try: app=self.app,
machines = self.state_manager.get_all_machines() config=self.config,
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()} state_manager=self.state_manager,
except Exception as e: auto_recording_manager=self.auto_recording_manager,
self.logger.error(f"Error getting machines: {e}") logger=self.logger
raise HTTPException(status_code=500, detail=str(e)) )
@self.app.get("/mqtt/status", response_model=MQTTStatusResponse) register_recordings_routes(
async def get_mqtt_status(): app=self.app,
"""Get MQTT client status and statistics""" state_manager=self.state_manager,
try: logger=self.logger
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: # WebSocket endpoint (not in route modules)
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))
@self.app.websocket("/ws") @self.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time updates""" """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 threading
import time import time
import logging import logging
import contextlib
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
# Add camera SDK to path # Add camera SDK to path
@@ -20,30 +19,8 @@ from ..core.config import Config
from ..core.state_manager import StateManager, CameraStatus from ..core.state_manager import StateManager, CameraStatus
from ..core.events import EventSystem, publish_camera_status_changed from ..core.events import EventSystem, publish_camera_status_changed
from .sdk_config import ensure_sdk_initialized from .sdk_config import ensure_sdk_initialized
from .utils import suppress_camera_errors
from .constants import CAMERA_TEST_CAPTURE_TIMEOUT
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
# Save original file descriptors
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
# Redirect stderr and stdout to devnull
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
os.close(devnull)
yield
finally:
# Restore original file descriptors
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)
class CameraMonitor: class CameraMonitor:
@@ -219,7 +196,7 @@ class CameraMonitor:
mvsdk.CameraPlay(hCamera) mvsdk.CameraPlay(hCamera)
# Try to capture with short timeout # 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) mvsdk.CameraReleaseImageBuffer(hCamera, pRawData)
# Success - camera is available # Success - camera is available

View File

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

View File

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