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:
437
CODE_QUALITY_IMPROVEMENTS.md
Normal file
437
CODE_QUALITY_IMPROVEMENTS.md
Normal 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
427
MODULARIZATION_PROPOSAL.md
Normal 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
249
REFACTORING_PLAN.md
Normal 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!**
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
|
||||||
@@ -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)}
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
|
||||||
@@ -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:
|
||||||
@@ -115,593 +124,57 @@ 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"""
|
||||||
|
|||||||
30
camera-management-api/usda_vision_system/camera/constants.py
Normal file
30
camera-management-api/usda_vision_system/camera/constants.py
Normal 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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
31
camera-management-api/usda_vision_system/camera/utils.py
Normal file
31
camera-management-api/usda_vision_system/camera/utils.py
Normal 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)
|
||||||
|
|
||||||
Reference in New Issue
Block a user