diff --git a/CODE_QUALITY_IMPROVEMENTS.md b/CODE_QUALITY_IMPROVEMENTS.md new file mode 100644 index 0000000..8d69938 --- /dev/null +++ b/CODE_QUALITY_IMPROVEMENTS.md @@ -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! + diff --git a/MODULARIZATION_PROPOSAL.md b/MODULARIZATION_PROPOSAL.md new file mode 100644 index 0000000..bb165c7 --- /dev/null +++ b/MODULARIZATION_PROPOSAL.md @@ -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. + diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 0000000..e084066 --- /dev/null +++ b/REFACTORING_PLAN.md @@ -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!** + diff --git a/camera-management-api/usda_vision_system/api/routes/__init__.py b/camera-management-api/usda_vision_system/api/routes/__init__.py new file mode 100644 index 0000000..3d8fb7b --- /dev/null +++ b/camera-management-api/usda_vision_system/api/routes/__init__.py @@ -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", +] + diff --git a/camera-management-api/usda_vision_system/api/routes/auto_recording_routes.py b/camera-management-api/usda_vision_system/api/routes/auto_recording_routes.py new file mode 100644 index 0000000..6fb7932 --- /dev/null +++ b/camera-management-api/usda_vision_system/api/routes/auto_recording_routes.py @@ -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)) + diff --git a/camera-management-api/usda_vision_system/api/routes/camera_routes.py b/camera-management-api/usda_vision_system/api/routes/camera_routes.py new file mode 100644 index 0000000..e4f175b --- /dev/null +++ b/camera-management-api/usda_vision_system/api/routes/camera_routes.py @@ -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)} + diff --git a/camera-management-api/usda_vision_system/api/routes/mqtt_routes.py b/camera-management-api/usda_vision_system/api/routes/mqtt_routes.py new file mode 100644 index 0000000..ed81281 --- /dev/null +++ b/camera-management-api/usda_vision_system/api/routes/mqtt_routes.py @@ -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)) + diff --git a/camera-management-api/usda_vision_system/api/routes/recording_routes.py b/camera-management-api/usda_vision_system/api/routes/recording_routes.py new file mode 100644 index 0000000..ec9c1ab --- /dev/null +++ b/camera-management-api/usda_vision_system/api/routes/recording_routes.py @@ -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)) + diff --git a/camera-management-api/usda_vision_system/api/routes/recordings_routes.py b/camera-management-api/usda_vision_system/api/routes/recordings_routes.py new file mode 100644 index 0000000..fc006f6 --- /dev/null +++ b/camera-management-api/usda_vision_system/api/routes/recordings_routes.py @@ -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)) + diff --git a/camera-management-api/usda_vision_system/api/routes/storage_routes.py b/camera-management-api/usda_vision_system/api/routes/storage_routes.py new file mode 100644 index 0000000..6ddeeb6 --- /dev/null +++ b/camera-management-api/usda_vision_system/api/routes/storage_routes.py @@ -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)) + diff --git a/camera-management-api/usda_vision_system/api/routes/system_routes.py b/camera-management-api/usda_vision_system/api/routes/system_routes.py new file mode 100644 index 0000000..a2a0054 --- /dev/null +++ b/camera-management-api/usda_vision_system/api/routes/system_routes.py @@ -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)) + diff --git a/camera-management-api/usda_vision_system/api/server.py b/camera-management-api/usda_vision_system/api/server.py index d744cdf..0f8de13 100644 --- a/camera-management-api/usda_vision_system/api/server.py +++ b/camera-management-api/usda_vision_system/api/server.py @@ -23,6 +23,15 @@ from ..core.events import EventSystem, EventType, Event from ..storage.manager import StorageManager from ..video.integration import create_video_module, VideoModule from .models import * +from .routes import ( + register_system_routes, + register_camera_routes, + register_recording_routes, + register_mqtt_routes, + register_storage_routes, + register_auto_recording_routes, + register_recordings_routes, +) class WebSocketManager: @@ -114,594 +123,58 @@ class APIServer: def _setup_routes(self): """Setup API routes""" - - @self.app.get("/", response_model=SuccessResponse) - async def root(): - return SuccessResponse(message="USDA Vision Camera System API") - - @self.app.get("/health") - async def health_check(): - return {"status": "healthy", "timestamp": datetime.now().isoformat()} - - @self.app.get("/system/status", response_model=SystemStatusResponse) - async def get_system_status(): - """Get overall system status""" - try: - summary = self.state_manager.get_system_summary() - uptime = (datetime.now() - self.server_start_time).total_seconds() - - return SystemStatusResponse(system_started=summary["system_started"], mqtt_connected=summary["mqtt_connected"], last_mqtt_message=summary["last_mqtt_message"], machines=summary["machines"], cameras=summary["cameras"], active_recordings=summary["active_recordings"], total_recordings=summary["total_recordings"], uptime_seconds=uptime) - except Exception as e: - self.logger.error(f"Error getting system status: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/system/video-module") - async def get_video_module_status(): - """Get video module status and configuration""" - try: - if self.video_module: - status = self.video_module.get_module_status() - status["enabled"] = True - return status - else: - return {"enabled": False, "error": "Video module not initialized"} - except Exception as e: - self.logger.error(f"Error getting video module status: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/machines", response_model=Dict[str, MachineStatusResponse]) - async def get_machines(): - """Get all machine statuses""" - try: - machines = self.state_manager.get_all_machines() - return {name: MachineStatusResponse(name=machine.name, state=machine.state.value, last_updated=machine.last_updated.isoformat(), last_message=machine.last_message, mqtt_topic=machine.mqtt_topic) for name, machine in machines.items()} - except Exception as e: - self.logger.error(f"Error getting machines: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/mqtt/status", response_model=MQTTStatusResponse) - async def get_mqtt_status(): - """Get MQTT client status and statistics""" - try: - status = self.mqtt_client.get_status() - return MQTTStatusResponse(connected=status["connected"], broker_host=status["broker_host"], broker_port=status["broker_port"], subscribed_topics=status["subscribed_topics"], last_message_time=status["last_message_time"], message_count=status["message_count"], error_count=status["error_count"], uptime_seconds=status["uptime_seconds"]) - except Exception as e: - self.logger.error(f"Error getting MQTT status: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/mqtt/events", response_model=MQTTEventsHistoryResponse) - async def get_mqtt_events(limit: int = Query(default=5, ge=1, le=50, description="Number of recent events to retrieve")): - """Get recent MQTT events history""" - try: - events = self.state_manager.get_recent_mqtt_events(limit) - total_events = self.state_manager.get_mqtt_event_count() - - # Convert events to response format - event_responses = [MQTTEventResponse(machine_name=event.machine_name, topic=event.topic, payload=event.payload, normalized_state=event.normalized_state, timestamp=event.timestamp.isoformat(), message_number=event.message_number) for event in events] - - last_updated = events[0].timestamp.isoformat() if events else None - - return MQTTEventsHistoryResponse(events=event_responses, total_events=total_events, last_updated=last_updated) - except Exception as e: - self.logger.error(f"Error getting MQTT events: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/cameras", response_model=Dict[str, CameraStatusResponse]) - async def get_cameras(): - """Get all camera statuses""" - try: - cameras = self.state_manager.get_all_cameras() - return { - name: CameraStatusResponse( - name=camera.name, - status=camera.status.value, - is_recording=camera.is_recording, - last_checked=camera.last_checked.isoformat(), - last_error=camera.last_error, - device_info=camera.device_info, - current_recording_file=camera.current_recording_file, - recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None, - auto_recording_enabled=camera.auto_recording_enabled, - auto_recording_active=camera.auto_recording_active, - auto_recording_failure_count=camera.auto_recording_failure_count, - auto_recording_last_attempt=camera.auto_recording_last_attempt.isoformat() if camera.auto_recording_last_attempt else None, - auto_recording_last_error=camera.auto_recording_last_error, - ) - for name, camera in cameras.items() - } - except Exception as e: - self.logger.error(f"Error getting cameras: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse) - async def get_camera_status(camera_name: str): - """Get specific camera status""" - try: - camera = self.state_manager.get_camera_status(camera_name) - if not camera: - raise HTTPException(status_code=404, detail=f"Camera not found: {camera_name}") - - return CameraStatusResponse(name=camera.name, status=camera.status.value, is_recording=camera.is_recording, last_checked=camera.last_checked.isoformat(), last_error=camera.last_error, device_info=camera.device_info, current_recording_file=camera.current_recording_file, recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None) - except HTTPException: - raise - except Exception as e: - self.logger.error(f"Error getting camera status: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse) - async def start_recording(camera_name: str, request: StartRecordingRequest): - """Manually start recording for a camera""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.manual_start_recording(camera_name=camera_name, filename=request.filename, exposure_ms=request.exposure_ms, gain=request.gain, fps=request.fps) - - if success: - # Get the actual filename that was used (with datetime prefix) - actual_filename = request.filename - if request.filename: - from ..core.timezone_utils import format_filename_timestamp - - timestamp = format_filename_timestamp() - actual_filename = f"{timestamp}_{request.filename}" - - return StartRecordingResponse(success=True, message=f"Recording started for {camera_name}", filename=actual_filename) - else: - return StartRecordingResponse(success=False, message=f"Failed to start recording for {camera_name}") - except Exception as e: - self.logger.error(f"Error starting recording: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse) - async def stop_recording(camera_name: str): - """Manually stop recording for a camera""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.manual_stop_recording(camera_name) - - if success: - return StopRecordingResponse(success=True, message=f"Recording stopped for {camera_name}") - else: - return StopRecordingResponse(success=False, message=f"Failed to stop recording for {camera_name}") - except Exception as e: - self.logger.error(f"Error stopping recording: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/test-connection", response_model=CameraTestResponse) - async def test_camera_connection(camera_name: str): - """Test camera connection""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.test_camera_connection(camera_name) - - if success: - return CameraTestResponse(success=True, message=f"Camera {camera_name} connection test passed", camera_name=camera_name) - else: - return CameraTestResponse(success=False, message=f"Camera {camera_name} connection test failed", camera_name=camera_name) - except Exception as e: - self.logger.error(f"Error testing camera connection: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/cameras/{camera_name}/stream") - async def camera_stream(camera_name: str): - """Get live MJPEG stream from camera""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - # Get camera streamer - streamer = self.camera_manager.get_camera_streamer(camera_name) - if not streamer: - raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found") - - # Start streaming if not already active - if not streamer.is_streaming(): - success = streamer.start_streaming() - if not success: - raise HTTPException(status_code=500, detail=f"Failed to start streaming for camera {camera_name}") - - # Return MJPEG stream - return StreamingResponse(streamer.get_frame_generator(), media_type="multipart/x-mixed-replace; boundary=frame") - - except HTTPException: - raise - except Exception as e: - self.logger.error(f"Error starting camera stream: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/start-stream") - async def start_camera_stream(camera_name: str): - """Start streaming for a camera""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.start_camera_streaming(camera_name) - if success: - return {"success": True, "message": f"Started streaming for camera {camera_name}"} - else: - return {"success": False, "message": f"Failed to start streaming for camera {camera_name}"} - - except Exception as e: - self.logger.error(f"Error starting camera stream: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/stop-stream") - async def stop_camera_stream(camera_name: str): - """Stop streaming for a camera""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.stop_camera_streaming(camera_name) - if success: - return {"success": True, "message": f"Stopped streaming for camera {camera_name}"} - else: - return {"success": False, "message": f"Failed to stop streaming for camera {camera_name}"} - - except Exception as e: - self.logger.error(f"Error stopping camera stream: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/start-rtsp") - async def start_camera_rtsp_stream(camera_name: str): - """Start RTSP streaming for a camera to MediaMTX""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.start_camera_rtsp_streaming(camera_name) - if success: - rtsp_url = f"rtsp://{os.getenv('MEDIAMTX_HOST', 'localhost')}:{os.getenv('MEDIAMTX_RTSP_PORT', '8554')}/{camera_name}" - return { - "success": True, - "message": f"Started RTSP streaming for camera {camera_name}", - "rtsp_url": rtsp_url - } - else: - return {"success": False, "message": f"Failed to start RTSP streaming for camera {camera_name}"} - - except Exception as e: - self.logger.error(f"Error starting RTSP stream: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/stop-rtsp") - async def stop_camera_rtsp_stream(camera_name: str): - """Stop RTSP streaming for a camera""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.stop_camera_rtsp_streaming(camera_name) - if success: - return {"success": True, "message": f"Stopped RTSP streaming for camera {camera_name}"} - else: - return {"success": False, "message": f"Failed to stop RTSP streaming for camera {camera_name}"} - - except Exception as e: - self.logger.error(f"Error stopping RTSP stream: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/cameras/{camera_name}/config", response_model=CameraConfigResponse) - async def get_camera_config(camera_name: str): - """Get camera configuration""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - config = self.camera_manager.get_camera_config(camera_name) - if not config: - raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found") - - return CameraConfigResponse( - name=config.name, - machine_topic=config.machine_topic, - storage_path=config.storage_path, - enabled=config.enabled, - # Auto-recording settings - auto_start_recording_enabled=config.auto_start_recording_enabled, - auto_recording_max_retries=config.auto_recording_max_retries, - auto_recording_retry_delay_seconds=config.auto_recording_retry_delay_seconds, - # Basic settings - exposure_ms=config.exposure_ms, - gain=config.gain, - target_fps=config.target_fps, - # Video recording settings - video_format=config.video_format, - video_codec=config.video_codec, - video_quality=config.video_quality, - # Image Quality Settings - sharpness=config.sharpness, - contrast=config.contrast, - saturation=config.saturation, - gamma=config.gamma, - # Noise Reduction - noise_filter_enabled=config.noise_filter_enabled, - denoise_3d_enabled=config.denoise_3d_enabled, - # Color Settings - auto_white_balance=config.auto_white_balance, - color_temperature_preset=config.color_temperature_preset, - # Manual White Balance RGB Gains - wb_red_gain=config.wb_red_gain, - wb_green_gain=config.wb_green_gain, - wb_blue_gain=config.wb_blue_gain, - # Advanced Settings - anti_flicker_enabled=config.anti_flicker_enabled, - light_frequency=config.light_frequency, - bit_depth=config.bit_depth, - # HDR Settings - hdr_enabled=config.hdr_enabled, - hdr_gain_mode=config.hdr_gain_mode, - ) - - except HTTPException: - raise - except Exception as e: - self.logger.error(f"Error getting camera config: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.put("/cameras/{camera_name}/config") - async def update_camera_config(camera_name: str, request: CameraConfigRequest): - """Update camera configuration""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - # Convert request to dict, excluding None values - config_updates = {k: v for k, v in request.dict().items() if v is not None} - - if not config_updates: - raise HTTPException(status_code=400, detail="No configuration updates provided") - - success = self.camera_manager.update_camera_config(camera_name, **config_updates) - if success: - return {"success": True, "message": f"Camera {camera_name} configuration updated", "updated_settings": list(config_updates.keys())} - else: - raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found or update failed") - - except HTTPException: - raise - except Exception as e: - self.logger.error(f"Error updating camera config: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/apply-config") - async def apply_camera_config(camera_name: str): - """Apply current configuration to active camera (requires camera restart)""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.apply_camera_config(camera_name) - if success: - return {"success": True, "message": f"Configuration applied to camera {camera_name}"} - else: - return {"success": False, "message": f"Failed to apply configuration to camera {camera_name}"} - - except Exception as e: - self.logger.error(f"Error applying camera config: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/reconnect", response_model=CameraRecoveryResponse) - async def reconnect_camera(camera_name: str): - """Reconnect to a camera""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.reconnect_camera(camera_name) - - if success: - return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reconnected successfully", camera_name=camera_name, operation="reconnect") - else: - return CameraRecoveryResponse(success=False, message=f"Failed to reconnect camera {camera_name}", camera_name=camera_name, operation="reconnect") - except Exception as e: - self.logger.error(f"Error reconnecting camera: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/restart-grab", response_model=CameraRecoveryResponse) - async def restart_camera_grab(camera_name: str): - """Restart camera grab process""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.restart_camera_grab(camera_name) - - if success: - return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} grab process restarted successfully", camera_name=camera_name, operation="restart-grab") - else: - return CameraRecoveryResponse(success=False, message=f"Failed to restart grab process for camera {camera_name}", camera_name=camera_name, operation="restart-grab") - except Exception as e: - self.logger.error(f"Error restarting camera grab: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/reset-timestamp", response_model=CameraRecoveryResponse) - async def reset_camera_timestamp(camera_name: str): - """Reset camera timestamp""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.reset_camera_timestamp(camera_name) - - if success: - return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} timestamp reset successfully", camera_name=camera_name, operation="reset-timestamp") - else: - return CameraRecoveryResponse(success=False, message=f"Failed to reset timestamp for camera {camera_name}", camera_name=camera_name, operation="reset-timestamp") - except Exception as e: - self.logger.error(f"Error resetting camera timestamp: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/full-reset", response_model=CameraRecoveryResponse) - async def full_reset_camera(camera_name: str): - """Perform full camera reset (uninitialize and reinitialize)""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.full_reset_camera(camera_name) - - if success: - return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} full reset completed successfully", camera_name=camera_name, operation="full-reset") - else: - return CameraRecoveryResponse(success=False, message=f"Failed to perform full reset for camera {camera_name}", camera_name=camera_name, operation="full-reset") - except Exception as e: - self.logger.error(f"Error performing full camera reset: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/reinitialize", response_model=CameraRecoveryResponse) - async def reinitialize_camera(camera_name: str): - """Reinitialize a failed camera""" - try: - if not self.camera_manager: - raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.reinitialize_failed_camera(camera_name) - - if success: - return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reinitialized successfully", camera_name=camera_name, operation="reinitialize") - else: - return CameraRecoveryResponse(success=False, message=f"Failed to reinitialize camera {camera_name}", camera_name=camera_name, operation="reinitialize") - except Exception as e: - self.logger.error(f"Error reinitializing camera: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/auto-recording/enable", response_model=AutoRecordingConfigResponse) - async def enable_auto_recording(camera_name: str): - """Enable auto-recording for a camera""" - try: - if not self.auto_recording_manager: - raise HTTPException(status_code=503, detail="Auto-recording manager not available") - - # Update camera configuration - camera_config = self.config.get_camera_by_name(camera_name) - if not camera_config: - raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found") - - camera_config.auto_start_recording_enabled = True - self.config.save_config() - - # Update camera status in state manager - camera_info = self.state_manager.get_camera_status(camera_name) - if camera_info: - camera_info.auto_recording_enabled = True - - return AutoRecordingConfigResponse(success=True, message=f"Auto-recording enabled for camera {camera_name}", camera_name=camera_name, enabled=True) - except HTTPException: - raise - except Exception as e: - self.logger.error(f"Error enabling auto-recording for camera {camera_name}: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/cameras/{camera_name}/auto-recording/disable", response_model=AutoRecordingConfigResponse) - async def disable_auto_recording(camera_name: str): - """Disable auto-recording for a camera""" - try: - if not self.auto_recording_manager: - raise HTTPException(status_code=503, detail="Auto-recording manager not available") - - # Update camera configuration - camera_config = self.config.get_camera_by_name(camera_name) - if not camera_config: - raise HTTPException(status_code=404, detail=f"Camera {camera_name} not found") - - camera_config.auto_start_recording_enabled = False - self.config.save_config() - - # Update camera status in state manager - camera_info = self.state_manager.get_camera_status(camera_name) - if camera_info: - camera_info.auto_recording_enabled = False - camera_info.auto_recording_active = False - - return AutoRecordingConfigResponse(success=True, message=f"Auto-recording disabled for camera {camera_name}", camera_name=camera_name, enabled=False) - except HTTPException: - raise - except Exception as e: - self.logger.error(f"Error disabling auto-recording for camera {camera_name}: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/auto-recording/status", response_model=AutoRecordingStatusResponse) - async def get_auto_recording_status(): - """Get auto-recording manager status""" - try: - if not self.auto_recording_manager: - raise HTTPException(status_code=503, detail="Auto-recording manager not available") - - status = self.auto_recording_manager.get_status() - return AutoRecordingStatusResponse(**status) - except Exception as e: - self.logger.error(f"Error getting auto-recording status: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/recordings", response_model=Dict[str, RecordingInfoResponse]) - async def get_recordings(): - """Get all recording sessions""" - try: - recordings = self.state_manager.get_all_recordings() - return { - rid: RecordingInfoResponse( - camera_name=recording.camera_name, - filename=recording.filename, - start_time=recording.start_time.isoformat(), - state=recording.state.value, - end_time=recording.end_time.isoformat() if recording.end_time else None, - file_size_bytes=recording.file_size_bytes, - frame_count=recording.frame_count, - duration_seconds=(recording.end_time - recording.start_time).total_seconds() if recording.end_time else None, - error_message=recording.error_message, - ) - for rid, recording in recordings.items() - } - except Exception as e: - self.logger.error(f"Error getting recordings: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.get("/storage/stats", response_model=StorageStatsResponse) - async def get_storage_stats(): - """Get storage statistics""" - try: - stats = self.storage_manager.get_storage_statistics() - return StorageStatsResponse(**stats) - except Exception as e: - self.logger.error(f"Error getting storage stats: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/storage/files", response_model=FileListResponse) - async def get_files(request: FileListRequest): - """Get list of recording files""" - try: - start_date = None - end_date = None - - if request.start_date: - start_date = datetime.fromisoformat(request.start_date) - if request.end_date: - end_date = datetime.fromisoformat(request.end_date) - - files = self.storage_manager.get_recording_files(camera_name=request.camera_name, start_date=start_date, end_date=end_date, limit=request.limit) - - return FileListResponse(files=files, total_count=len(files)) - except Exception as e: - self.logger.error(f"Error getting files: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/storage/cleanup", response_model=CleanupResponse) - async def cleanup_storage(request: CleanupRequest): - """Clean up old storage files""" - try: - result = self.storage_manager.cleanup_old_files(request.max_age_days) - return CleanupResponse(**result) - except Exception as e: - self.logger.error(f"Error during cleanup: {e}") - raise HTTPException(status_code=500, detail=str(e)) - + + # Register routes from modules + register_system_routes( + app=self.app, + state_manager=self.state_manager, + video_module=self.video_module, + server_start_time=self.server_start_time, + logger=self.logger + ) + + register_camera_routes( + app=self.app, + config=self.config, + state_manager=self.state_manager, + camera_manager=self.camera_manager, + logger=self.logger + ) + + register_recording_routes( + app=self.app, + camera_manager=self.camera_manager, + logger=self.logger + ) + + register_mqtt_routes( + app=self.app, + mqtt_client=self.mqtt_client, + state_manager=self.state_manager, + logger=self.logger + ) + + register_storage_routes( + app=self.app, + storage_manager=self.storage_manager, + logger=self.logger + ) + + register_auto_recording_routes( + app=self.app, + config=self.config, + state_manager=self.state_manager, + auto_recording_manager=self.auto_recording_manager, + logger=self.logger + ) + + register_recordings_routes( + app=self.app, + state_manager=self.state_manager, + logger=self.logger + ) + + # WebSocket endpoint (not in route modules) @self.app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): """WebSocket endpoint for real-time updates""" diff --git a/camera-management-api/usda_vision_system/camera/constants.py b/camera-management-api/usda_vision_system/camera/constants.py new file mode 100644 index 0000000..aefe14b --- /dev/null +++ b/camera-management-api/usda_vision_system/camera/constants.py @@ -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 + diff --git a/camera-management-api/usda_vision_system/camera/monitor.py b/camera-management-api/usda_vision_system/camera/monitor.py index 9107537..c0acb5b 100644 --- a/camera-management-api/usda_vision_system/camera/monitor.py +++ b/camera-management-api/usda_vision_system/camera/monitor.py @@ -9,7 +9,6 @@ import os import threading import time import logging -import contextlib from typing import Dict, List, Optional, Any # Add camera SDK to path @@ -20,30 +19,8 @@ from ..core.config import Config from ..core.state_manager import StateManager, CameraStatus from ..core.events import EventSystem, publish_camera_status_changed from .sdk_config import ensure_sdk_initialized - - -@contextlib.contextmanager -def suppress_camera_errors(): - """Context manager to temporarily suppress camera SDK error output""" - # Save original file descriptors - original_stderr = os.dup(2) - original_stdout = os.dup(1) - - try: - # Redirect stderr and stdout to devnull - devnull = os.open(os.devnull, os.O_WRONLY) - os.dup2(devnull, 2) # stderr - os.dup2(devnull, 1) # stdout (in case SDK uses stdout) - os.close(devnull) - - yield - - finally: - # Restore original file descriptors - os.dup2(original_stderr, 2) - os.dup2(original_stdout, 1) - os.close(original_stderr) - os.close(original_stdout) +from .utils import suppress_camera_errors +from .constants import CAMERA_TEST_CAPTURE_TIMEOUT class CameraMonitor: @@ -219,7 +196,7 @@ class CameraMonitor: mvsdk.CameraPlay(hCamera) # Try to capture with short timeout - pRawData, FrameHead = mvsdk.CameraGetImageBuffer(hCamera, 500) + pRawData, FrameHead = mvsdk.CameraGetImageBuffer(hCamera, CAMERA_TEST_CAPTURE_TIMEOUT) mvsdk.CameraReleaseImageBuffer(hCamera, pRawData) # Success - camera is available diff --git a/camera-management-api/usda_vision_system/camera/recorder.py b/camera-management-api/usda_vision_system/camera/recorder.py index ec9c98c..67331c5 100644 --- a/camera-management-api/usda_vision_system/camera/recorder.py +++ b/camera-management-api/usda_vision_system/camera/recorder.py @@ -11,7 +11,6 @@ import time import logging import cv2 import numpy as np -import contextlib import queue from typing import Optional, Dict, Any from datetime import datetime @@ -26,30 +25,7 @@ from ..core.state_manager import StateManager from ..core.events import EventSystem, publish_recording_started, publish_recording_stopped, publish_recording_error from ..core.timezone_utils import now_atlanta, format_filename_timestamp from .sdk_config import ensure_sdk_initialized - - -@contextlib.contextmanager -def suppress_camera_errors(): - """Context manager to temporarily suppress camera SDK error output""" - # Save original file descriptors - original_stderr = os.dup(2) - original_stdout = os.dup(1) - - try: - # Redirect stderr and stdout to devnull - devnull = os.open(os.devnull, os.O_WRONLY) - os.dup2(devnull, 2) # stderr - os.dup2(devnull, 1) # stdout (in case SDK uses stdout) - os.close(devnull) - - yield - - finally: - # Restore original file descriptors - os.dup2(original_stderr, 2) - os.dup2(original_stdout, 1) - os.close(original_stderr) - os.close(original_stdout) +from .utils import suppress_camera_errors class CameraRecorder: @@ -537,7 +513,7 @@ class CameraRecorder: """Test if camera can capture frames""" try: # Try to capture one frame - pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 1000) # 1 second timeout + pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_TEST_CAPTURE_TIMEOUT) mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead) mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData) return True @@ -686,7 +662,7 @@ class CameraRecorder: continue else: # Capture frame directly from camera - pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 200) # 200ms timeout + pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_GET_BUFFER_TIMEOUT) # Process frame mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead) @@ -770,7 +746,7 @@ class CameraRecorder: self.logger.info(f"Using frame dimensions from streamer frame: {frame_size}") elif self.hCamera: # Get frame dimensions by capturing a test frame from camera - pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 1000) + pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_INIT_TIMEOUT) mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead) mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData) frame_size = (FrameHead.iWidth, FrameHead.iHeight) @@ -779,7 +755,7 @@ class CameraRecorder: if self.streamer and self.streamer.hCamera: try: with suppress_camera_errors(): - pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.streamer.hCamera, 1000) + pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.streamer.hCamera, CAMERA_INIT_TIMEOUT) mvsdk.CameraReleaseImageBuffer(self.streamer.hCamera, pRawData) frame_size = (FrameHead.iWidth, FrameHead.iHeight) self.logger.info(f"Got frame dimensions from streamer's camera: {frame_size}") @@ -798,8 +774,8 @@ class CameraRecorder: # Set up video writer with configured codec fourcc = cv2.VideoWriter_fourcc(*self.camera_config.video_codec) - # Use 30 FPS for video writer if target_fps is 0 (unlimited) - video_fps = self.camera_config.target_fps if self.camera_config.target_fps > 0 else 30.0 + # Use default FPS for video writer if target_fps is 0 (unlimited) + video_fps = self.camera_config.target_fps if self.camera_config.target_fps > 0 else DEFAULT_VIDEO_FPS # Create video writer with quality settings self.video_writer = cv2.VideoWriter(self.output_filename, fourcc, video_fps, frame_size) @@ -883,7 +859,7 @@ class CameraRecorder: # Small delay to ensure file system sync import time - time.sleep(0.1) + time.sleep(BRIEF_PAUSE_SLEEP) # Verify file exists and has content if self.output_filename and os.path.exists(self.output_filename): diff --git a/camera-management-api/usda_vision_system/camera/streamer.py b/camera-management-api/usda_vision_system/camera/streamer.py index f92e7a0..04d7e98 100644 --- a/camera-management-api/usda_vision_system/camera/streamer.py +++ b/camera-management-api/usda_vision_system/camera/streamer.py @@ -12,7 +12,6 @@ import time import logging import cv2 import numpy as np -import contextlib import subprocess from typing import Optional, Dict, Any, Generator from datetime import datetime @@ -26,30 +25,19 @@ from ..core.config import CameraConfig from ..core.state_manager import StateManager from ..core.events import EventSystem from .sdk_config import ensure_sdk_initialized - - -@contextlib.contextmanager -def suppress_camera_errors(): - """Context manager to temporarily suppress camera SDK error output""" - # Save original file descriptors - original_stderr = os.dup(2) - original_stdout = os.dup(1) - - try: - # Redirect stderr and stdout to devnull - devnull = os.open(os.devnull, os.O_WRONLY) - os.dup2(devnull, 2) # stderr - os.dup2(devnull, 1) # stdout (in case SDK uses stdout) - os.close(devnull) - - yield - - finally: - # Restore original file descriptors - os.dup2(original_stderr, 2) - os.dup2(original_stdout, 1) - os.close(original_stderr) - os.close(original_stdout) +from .utils import suppress_camera_errors +from .constants import ( + MJPEG_QUEUE_MAXSIZE, + RTSP_QUEUE_MAXSIZE, + RECORDING_QUEUE_MAXSIZE, + PREVIEW_FPS, + RTSP_FPS, + PREVIEW_JPEG_QUALITY, + CAMERA_GET_BUFFER_TIMEOUT, + CAMERA_TEST_CAPTURE_TIMEOUT, + STREAMING_LOOP_SLEEP, + BRIEF_PAUSE_SLEEP, +) class CameraStreamer: @@ -78,17 +66,17 @@ class CameraStreamer: self._rtsp_thread: Optional[threading.Thread] = None self._stop_streaming_event = threading.Event() self._stop_rtsp_event = threading.Event() - self._frame_queue = queue.Queue(maxsize=5) # Buffer for latest frames (for MJPEG streaming) - self._rtsp_frame_queue = queue.Queue(maxsize=10) # Buffer for RTSP frames (larger buffer for smoother streaming) - self._recording_frame_queue = queue.Queue(maxsize=30) # Buffer for recording frames (shared with recorder) + self._frame_queue = queue.Queue(maxsize=MJPEG_QUEUE_MAXSIZE) # Buffer for latest frames (for MJPEG streaming) + self._rtsp_frame_queue = queue.Queue(maxsize=RTSP_QUEUE_MAXSIZE) # Buffer for RTSP frames (larger buffer for smoother streaming) + self._recording_frame_queue = queue.Queue(maxsize=RECORDING_QUEUE_MAXSIZE) # Buffer for recording frames (shared with recorder) self._lock = threading.RLock() # Stream settings (optimized for preview) - self.preview_fps = 10.0 # Lower FPS for preview to reduce load - self.preview_quality = 70 # JPEG quality for streaming + self.preview_fps = PREVIEW_FPS # Lower FPS for preview to reduce load + self.preview_quality = PREVIEW_JPEG_QUALITY # JPEG quality for streaming # RTSP settings - self.rtsp_fps = 15.0 # RTSP FPS (can be higher than MJPEG preview) + self.rtsp_fps = RTSP_FPS # RTSP FPS (can be higher than MJPEG preview) # Use MEDIAMTX_HOST env var if set, otherwise default to localhost # Note: If API uses network_mode: host, MediaMTX container ports are exposed to host # So localhost should work, but MediaMTX must be accessible on that port @@ -254,7 +242,7 @@ class CameraStreamer: if frame_bytes: yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame_bytes + b"\r\n") else: - time.sleep(0.1) # Wait a bit if no frame available + time.sleep(STREAMING_LOOP_SLEEP) # Wait a bit if no frame available def _initialize_camera(self) -> bool: """Initialize camera for streaming (separate from recording)""" @@ -366,11 +354,11 @@ class CameraStreamer: try: # If using shared camera, skip capture - recorder will populate queues if self._using_shared_camera: - time.sleep(0.1) # Just wait, recorder populates queues + time.sleep(STREAMING_LOOP_SLEEP) # Just wait, recorder populates queues continue # Capture frame with timeout - pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 200) # 200ms timeout + pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_GET_BUFFER_TIMEOUT) # Process frame mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead) @@ -431,7 +419,7 @@ class CameraStreamer: except Exception as e: if not self._stop_streaming_event.is_set(): self.logger.error(f"Error in streaming loop: {e}") - time.sleep(0.1) # Brief pause before retrying + time.sleep(BRIEF_PAUSE_SLEEP) # Brief pause before retrying except Exception as e: self.logger.error(f"Fatal error in streaming loop: {e}") diff --git a/camera-management-api/usda_vision_system/camera/utils.py b/camera-management-api/usda_vision_system/camera/utils.py new file mode 100644 index 0000000..cdb235d --- /dev/null +++ b/camera-management-api/usda_vision_system/camera/utils.py @@ -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) +