feat: Add MQTT publisher and tester scripts for USDA Vision Camera System

- Implemented mqtt_publisher_test.py for manual MQTT message publishing
- Created mqtt_test.py to test MQTT message reception and display statistics
- Developed test_api_changes.py to verify API changes for camera settings and filename handling
- Added test_camera_recovery_api.py for testing camera recovery API endpoints
- Introduced test_max_fps.py to demonstrate maximum FPS capture functionality
- Implemented test_mqtt_events_api.py to test MQTT events API endpoint
- Created test_mqtt_logging.py for enhanced MQTT logging and API endpoint testing
- Added sdk_config.py for SDK initialization and configuration with error suppression
This commit is contained in:
Alireza Vaezi
2025-07-28 16:30:14 -04:00
parent e2acebc056
commit 9cb043ef5f
40 changed files with 4485 additions and 838 deletions

2
.gitignore vendored
View File

@@ -85,3 +85,5 @@ Camera/log/*
# Python cache # Python cache
*/__pycache__/* */__pycache__/*
old tests/Camera/log/*
old tests/Camera/Data/*

175
API_CHANGES_SUMMARY.md Normal file
View File

@@ -0,0 +1,175 @@
# API Changes Summary: Camera Settings and Filename Handling
## Overview
Enhanced the `POST /cameras/{camera_name}/start-recording` API endpoint to accept optional camera settings (shutter speed/exposure, gain, and fps) and ensure all filenames have datetime prefixes.
## Changes Made
### 1. API Models (`usda_vision_system/api/models.py`)
- **Enhanced `StartRecordingRequest`** to include optional parameters:
- `exposure_ms: Optional[float]` - Exposure time in milliseconds
- `gain: Optional[float]` - Camera gain value
- `fps: Optional[float]` - Target frames per second
### 2. Camera Recorder (`usda_vision_system/camera/recorder.py`)
- **Added `update_camera_settings()` method** to dynamically update camera settings:
- Updates exposure time using `mvsdk.CameraSetExposureTime()`
- Updates gain using `mvsdk.CameraSetAnalogGain()`
- Updates target FPS in camera configuration
- Logs all setting changes
- Returns boolean indicating success/failure
### 3. Camera Manager (`usda_vision_system/camera/manager.py`)
- **Enhanced `manual_start_recording()` method** to accept new parameters:
- Added optional `exposure_ms`, `gain`, and `fps` parameters
- Calls `update_camera_settings()` if any settings are provided
- **Automatic datetime prefix**: Always prepends timestamp to filename
- If custom filename provided: `{timestamp}_{custom_filename}`
- If no filename provided: `{camera_name}_manual_{timestamp}.avi`
### 4. API Server (`usda_vision_system/api/server.py`)
- **Updated start-recording endpoint** to:
- Pass new camera settings to camera manager
- Handle filename response with datetime prefix
- Maintain backward compatibility with existing requests
### 5. API Tests (`api-tests.http`)
- **Added comprehensive test examples**:
- Basic recording (existing functionality)
- Recording with camera settings
- Recording with settings only (no filename)
- Different parameter combinations
## Usage Examples
### Basic Recording (unchanged)
```http
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"camera_name": "camera1",
"filename": "test.avi"
}
```
**Result**: File saved as `20241223_143022_test.avi`
### Recording with Camera Settings
```http
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"camera_name": "camera1",
"filename": "high_quality.avi",
"exposure_ms": 2.0,
"gain": 4.0,
"fps": 5.0
}
```
**Result**:
- Camera settings updated before recording
- File saved as `20241223_143022_high_quality.avi`
### Maximum FPS Recording
```http
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"camera_name": "camera1",
"filename": "max_speed.avi",
"exposure_ms": 0.1,
"gain": 1.0,
"fps": 0
}
```
**Result**:
- Camera captures at maximum possible speed (no delay between frames)
- Video file saved with 30 FPS metadata for proper playback
- Actual capture rate depends on camera hardware and exposure settings
### Settings Only (no filename)
```http
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"camera_name": "camera1",
"exposure_ms": 1.5,
"gain": 3.0,
"fps": 7.0
}
```
**Result**:
- Camera settings updated
- File saved as `camera1_manual_20241223_143022.avi`
## Key Features
### 1. **Backward Compatibility**
- All existing API calls continue to work unchanged
- New parameters are optional
- Default behavior preserved when no settings provided
### 2. **Automatic Datetime Prefix**
- **ALL filenames now have datetime prefix** regardless of what's sent
- Format: `YYYYMMDD_HHMMSS_` (Atlanta timezone)
- Ensures unique filenames and chronological ordering
### 3. **Dynamic Camera Settings**
- Settings can be changed per recording without restarting system
- Based on proven implementation from `old tests/camera_video_recorder.py`
- Proper error handling and logging
### 4. **Maximum FPS Capture**
- **`fps: 0`** = Capture at maximum possible speed (no delay between frames)
- **`fps > 0`** = Capture at specified frame rate with controlled timing
- **`fps` omitted** = Uses camera config default (usually 3.0 fps)
- Video files saved with 30 FPS metadata when fps=0 for proper playback
### 5. **Parameter Validation**
- Uses Pydantic models for automatic validation
- Optional parameters with proper type checking
- Descriptive field documentation
## Testing
Run the test script to verify functionality:
```bash
# Start the system first
python main.py
# In another terminal, run tests
python test_api_changes.py
```
The test script verifies:
- Basic recording functionality
- Camera settings application
- Filename datetime prefix handling
- API response accuracy
## Implementation Notes
### Camera Settings Mapping
- **Exposure**: Converted from milliseconds to microseconds for SDK
- **Gain**: Converted to camera units (multiplied by 100)
- **FPS**: Stored in camera config, used by recording loop
### Error Handling
- Settings update failures are logged but don't prevent recording
- Invalid camera names return appropriate HTTP errors
- Camera initialization failures are handled gracefully
### Filename Generation
- Uses `format_filename_timestamp()` from timezone utilities
- Ensures Atlanta timezone consistency
- Handles both custom and auto-generated filenames
## Similar to Old Implementation
The camera settings functionality mirrors the proven approach in `old tests/camera_video_recorder.py`:
- Same parameter names and ranges
- Same SDK function calls
- Same conversion factors
- Proven to work with the camera hardware

158
CAMERA_RECOVERY_GUIDE.md Normal file
View File

@@ -0,0 +1,158 @@
# Camera Recovery and Diagnostics Guide
This guide explains the new camera recovery functionality implemented in the USDA Vision Camera System API.
## Overview
The system now includes comprehensive camera recovery capabilities to handle connection issues, initialization failures, and other camera-related problems. These features use the underlying mvsdk (python demo) library functions to perform various recovery operations.
## Available Recovery Operations
### 1. Connection Test (`/cameras/{camera_name}/test-connection`)
- **Purpose**: Test if the camera connection is working
- **SDK Function**: `CameraConnectTest()`
- **Use Case**: Diagnose connection issues
- **HTTP Method**: POST
- **Response**: `CameraTestResponse`
### 2. Reconnect (`/cameras/{camera_name}/reconnect`)
- **Purpose**: Soft reconnection to the camera
- **SDK Function**: `CameraReConnect()`
- **Use Case**: Most common fix for connection issues
- **HTTP Method**: POST
- **Response**: `CameraRecoveryResponse`
### 3. Restart Grab (`/cameras/{camera_name}/restart-grab`)
- **Purpose**: Restart the camera grab process
- **SDK Function**: `CameraRestartGrab()`
- **Use Case**: Fix issues with image capture
- **HTTP Method**: POST
- **Response**: `CameraRecoveryResponse`
### 4. Reset Timestamp (`/cameras/{camera_name}/reset-timestamp`)
- **Purpose**: Reset camera timestamp
- **SDK Function**: `CameraRstTimeStamp()`
- **Use Case**: Fix timing-related issues
- **HTTP Method**: POST
- **Response**: `CameraRecoveryResponse`
### 5. Full Reset (`/cameras/{camera_name}/full-reset`)
- **Purpose**: Complete camera reset (uninitialize and reinitialize)
- **SDK Functions**: `CameraUnInit()` + `CameraInit()`
- **Use Case**: Hard reset for persistent issues
- **HTTP Method**: POST
- **Response**: `CameraRecoveryResponse`
### 6. Reinitialize (`/cameras/{camera_name}/reinitialize`)
- **Purpose**: Reinitialize cameras that failed initial setup
- **SDK Functions**: Complete recorder recreation
- **Use Case**: Cameras that never initialized properly
- **HTTP Method**: POST
- **Response**: `CameraRecoveryResponse`
## Recommended Troubleshooting Workflow
When a camera has issues, follow this order:
1. **Test Connection** - Diagnose the problem
```http
POST http://localhost:8000/cameras/camera1/test-connection
```
2. **Try Reconnect** - Most common fix
```http
POST http://localhost:8000/cameras/camera1/reconnect
```
3. **Restart Grab** - If reconnect doesn't work
```http
POST http://localhost:8000/cameras/camera1/restart-grab
```
4. **Full Reset** - For persistent issues
```http
POST http://localhost:8000/cameras/camera1/full-reset
```
5. **Reinitialize** - For cameras that never worked
```http
POST http://localhost:8000/cameras/camera1/reinitialize
```
## Response Format
All recovery operations return structured responses:
### CameraTestResponse
```json
{
"success": true,
"message": "Camera camera1 connection test passed",
"camera_name": "camera1",
"timestamp": "2024-01-01T12:00:00"
}
```
### CameraRecoveryResponse
```json
{
"success": true,
"message": "Camera camera1 reconnected successfully",
"camera_name": "camera1",
"operation": "reconnect",
"timestamp": "2024-01-01T12:00:00"
}
```
## Implementation Details
### CameraRecorder Methods
- `test_connection()`: Tests camera connection
- `reconnect()`: Performs soft reconnection
- `restart_grab()`: Restarts grab process
- `reset_timestamp()`: Resets timestamp
- `full_reset()`: Complete reset with cleanup and reinitialization
### CameraManager Methods
- `test_camera_connection(camera_name)`: Test specific camera
- `reconnect_camera(camera_name)`: Reconnect specific camera
- `restart_camera_grab(camera_name)`: Restart grab for specific camera
- `reset_camera_timestamp(camera_name)`: Reset timestamp for specific camera
- `full_reset_camera(camera_name)`: Full reset for specific camera
- `reinitialize_failed_camera(camera_name)`: Reinitialize failed camera
### State Management
All recovery operations automatically update the camera status in the state manager:
- Success: Status set to "connected"
- Failure: Status set to appropriate error state with error message
## Error Handling
The system includes comprehensive error handling:
- SDK exceptions are caught and logged
- State manager is updated with error information
- Proper HTTP status codes are returned
- Detailed error messages are provided
## Testing
Use the provided test files:
- `api-tests.http`: Manual API testing with VS Code REST Client
- `test_camera_recovery_api.py`: Automated testing script
## Safety Features
- Recording is automatically stopped before recovery operations
- Camera resources are properly cleaned up
- Thread-safe operations with proper locking
- Graceful error handling prevents system crashes
## Common Use Cases
1. **Camera Lost Connection**: Use reconnect
2. **Camera Won't Capture**: Use restart-grab
3. **Camera Initialization Failed**: Use reinitialize
4. **Persistent Issues**: Use full-reset
5. **Timing Problems**: Use reset-timestamp
This recovery system provides robust tools to handle most camera-related issues without requiring system restart or manual intervention.

187
MQTT_LOGGING_GUIDE.md Normal file
View File

@@ -0,0 +1,187 @@
# MQTT Console Logging & API Guide
## 🎯 Overview
Your USDA Vision Camera System now has **enhanced MQTT console logging** and **comprehensive API endpoints** for monitoring machine status via MQTT.
## ✨ What's New
### 1. **Enhanced Console Logging**
- **Colorful emoji-based console output** for all MQTT events
- **Real-time visibility** of MQTT connections, subscriptions, and messages
- **Clear status indicators** for debugging and monitoring
### 2. **New MQTT Status API Endpoint**
- **GET /mqtt/status** - Detailed MQTT client statistics
- **Message counts, error tracking, uptime monitoring**
- **Real-time connection status and broker information**
### 3. **Existing Machine Status APIs** (already available)
- **GET /machines** - All machine states from MQTT
- **GET /system/status** - Overall system status including MQTT
## 🖥️ Console Logging Examples
When you run the system, you'll see:
```bash
🔗 MQTT CONNECTED: 192.168.1.110:1883
📋 MQTT SUBSCRIBED: vibratory_conveyor → vision/vibratory_conveyor/state
📋 MQTT SUBSCRIBED: blower_separator → vision/blower_separator/state
📡 MQTT MESSAGE: vibratory_conveyor → on
📡 MQTT MESSAGE: blower_separator → off
⚠️ MQTT DISCONNECTED: Unexpected disconnection (code: 1)
🔗 MQTT CONNECTED: 192.168.1.110:1883
```
## 🌐 API Endpoints
### MQTT Status
```http
GET http://localhost:8000/mqtt/status
```
**Response:**
```json
{
"connected": true,
"broker_host": "192.168.1.110",
"broker_port": 1883,
"subscribed_topics": [
"vision/vibratory_conveyor/state",
"vision/blower_separator/state"
],
"last_message_time": "2025-07-28T12:00:00",
"message_count": 42,
"error_count": 0,
"uptime_seconds": 3600.5
}
```
### Machine Status
```http
GET http://localhost:8000/machines
```
**Response:**
```json
{
"vibratory_conveyor": {
"name": "vibratory_conveyor",
"state": "on",
"last_updated": "2025-07-28T12:00:00",
"last_message": "on",
"mqtt_topic": "vision/vibratory_conveyor/state"
},
"blower_separator": {
"name": "blower_separator",
"state": "off",
"last_updated": "2025-07-28T12:00:00",
"last_message": "off",
"mqtt_topic": "vision/blower_separator/state"
}
}
```
### System Status
```http
GET http://localhost:8000/system/status
```
**Response:**
```json
{
"system_started": true,
"mqtt_connected": true,
"last_mqtt_message": "2025-07-28T12:00:00",
"machines": { ... },
"cameras": { ... },
"active_recordings": 0,
"total_recordings": 5,
"uptime_seconds": 3600.5
}
```
## 🚀 How to Use
### 1. **Start the Full System**
```bash
python main.py
```
You'll see enhanced console logging for all MQTT events.
### 2. **Test MQTT Demo (MQTT only)**
```bash
python demo_mqtt_console.py
```
Shows just the MQTT client with enhanced logging.
### 3. **Test API Endpoints**
```bash
python test_mqtt_logging.py
```
Tests all the API endpoints and shows expected responses.
### 4. **Query APIs Directly**
```bash
# Check MQTT status
curl http://localhost:8000/mqtt/status
# Check machine states
curl http://localhost:8000/machines
# Check overall system status
curl http://localhost:8000/system/status
```
## 🔧 Configuration
The MQTT settings are in `config.json`:
```json
{
"mqtt": {
"broker_host": "192.168.1.110",
"broker_port": 1883,
"username": null,
"password": null,
"topics": {
"vibratory_conveyor": "vision/vibratory_conveyor/state",
"blower_separator": "vision/blower_separator/state"
}
}
}
```
## 🎨 Console Output Features
- **🔗 Connection Events**: Green for successful connections
- **📋 Subscriptions**: Blue for topic subscriptions
- **📡 Messages**: Real-time message display with machine name and payload
- **⚠️ Warnings**: Yellow for unexpected disconnections
- **❌ Errors**: Red for connection failures and errors
- **❓ Unknown Topics**: Purple for unrecognized MQTT topics
## 📊 Monitoring & Debugging
### Real-time Monitoring
- **Console**: Watch live MQTT events as they happen
- **API**: Query `/mqtt/status` for statistics and health
- **Logs**: Check `usda_vision_system.log` for detailed logs
### Troubleshooting
1. **No MQTT messages?** Check broker connectivity and topic configuration
2. **Connection issues?** Verify broker host/port in config.json
3. **API not responding?** Ensure the system is running with `python main.py`
## 🎯 Use Cases
1. **Development**: See MQTT messages in real-time while developing
2. **Debugging**: Identify connection issues and message patterns
3. **Monitoring**: Use APIs to build dashboards or monitoring tools
4. **Integration**: Query machine states from external applications
5. **Maintenance**: Track MQTT statistics and error rates
---
**🎉 Your MQTT monitoring is now fully enhanced with both console logging and comprehensive APIs!**

429
api-endpoints.http Normal file
View File

@@ -0,0 +1,429 @@
###############################################################################
# USDA Vision Camera System - Complete API Endpoints Documentation
# Base URL: http://localhost:8000
###############################################################################
###############################################################################
# SYSTEM ENDPOINTS
###############################################################################
### Root endpoint - API information
GET http://localhost:8000/
# Response: SuccessResponse
# {
# "success": true,
# "message": "USDA Vision Camera System API",
# "data": null,
# "timestamp": "2025-07-28T12:00:00"
# }
###
### Health check
GET http://localhost:8000/health
# Response: Simple health status
# {
# "status": "healthy",
# "timestamp": "2025-07-28T12:00:00"
# }
###
### Get system status
GET http://localhost:8000/system/status
# Response: SystemStatusResponse
# {
# "system_started": true,
# "mqtt_connected": true,
# "last_mqtt_message": "2025-07-28T12:00:00",
# "machines": {
# "vibratory_conveyor": {
# "name": "vibratory_conveyor",
# "state": "off",
# "last_updated": "2025-07-28T12:00:00"
# }
# },
# "cameras": {
# "camera1": {
# "name": "camera1",
# "status": "connected",
# "is_recording": false
# }
# },
# "active_recordings": 0,
# "total_recordings": 5,
# "uptime_seconds": 3600.5
# }
###############################################################################
# MACHINE ENDPOINTS
###############################################################################
### Get all machines status
GET http://localhost:8000/machines
# Response: Dict[str, MachineStatusResponse]
# {
# "vibratory_conveyor": {
# "name": "vibratory_conveyor",
# "state": "off",
# "last_updated": "2025-07-28T12:00:00",
# "last_message": "off",
# "mqtt_topic": "vision/vibratory_conveyor/state"
# },
# "blower_separator": {
# "name": "blower_separator",
# "state": "on",
# "last_updated": "2025-07-28T12:00:00",
# "last_message": "on",
# "mqtt_topic": "vision/blower_separator/state"
# }
# }
###############################################################################
# MQTT ENDPOINTS
###############################################################################
### Get MQTT status and statistics
GET http://localhost:8000/mqtt/status
# Response: MQTTStatusResponse
# {
# "connected": true,
# "broker_host": "192.168.1.110",
# "broker_port": 1883,
# "subscribed_topics": [
# "vision/vibratory_conveyor/state",
# "vision/blower_separator/state"
# ],
# "last_message_time": "2025-07-28T12:00:00",
# "message_count": 42,
# "error_count": 0,
# "uptime_seconds": 3600.5
# }
### Get recent MQTT events history
GET http://localhost:8000/mqtt/events
# Optional query parameter: limit (default: 5, max: 50)
# Response: MQTTEventsHistoryResponse
# {
# "events": [
# {
# "machine_name": "vibratory_conveyor",
# "topic": "vision/vibratory_conveyor/state",
# "payload": "on",
# "normalized_state": "on",
# "timestamp": "2025-07-28T15:30:45.123456",
# "message_number": 15
# },
# {
# "machine_name": "blower_separator",
# "topic": "vision/blower_separator/state",
# "payload": "off",
# "normalized_state": "off",
# "timestamp": "2025-07-28T15:29:12.654321",
# "message_number": 14
# }
# ],
# "total_events": 15,
# "last_updated": "2025-07-28T15:30:45.123456"
# }
### Get recent MQTT events with custom limit
GET http://localhost:8000/mqtt/events?limit=10
###############################################################################
# CAMERA ENDPOINTS
###############################################################################
### Get all cameras status
GET http://localhost:8000/cameras
# Response: Dict[str, CameraStatusResponse]
# {
# "camera1": {
# "name": "camera1",
# "status": "connected",
# "is_recording": false,
# "last_checked": "2025-07-28T12:00:00",
# "last_error": null,
# "device_info": {
# "friendly_name": "MindVision Camera",
# "serial_number": "ABC123"
# },
# "current_recording_file": null,
# "recording_start_time": null
# }
# }
###
### Get specific camera status
GET http://localhost:8000/cameras/camera1/status
### Get specific camera status
GET http://localhost:8000/cameras/camera2/status
# Response: CameraStatusResponse (same as above for single camera)
###############################################################################
# RECORDING CONTROL ENDPOINTS
###############################################################################
### Start recording (with all optional parameters)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"filename": "test_recording.avi",
"exposure_ms": 1.5,
"gain": 3.0,
"fps": 10.0
}
# Request Parameters (all optional):
# - filename: string - Custom filename (datetime prefix auto-added)
# - exposure_ms: float - Exposure time in milliseconds
# - gain: float - Camera gain value
# - fps: float - Target frames per second (0 = maximum speed, omit = use config default)
#
# Response: StartRecordingResponse
# {
# "success": true,
# "message": "Recording started for camera1",
# "filename": "20250728_120000_test_recording.avi"
# }
###
### Start recording (minimal - only filename)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"filename": "simple_test.avi"
}
###
### Start recording (only camera settings)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"exposure_ms": 2.0,
"gain": 4.0,
"fps": 0
}
###
### Start recording (empty body - all defaults)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{}
###
### Stop recording
POST http://localhost:8000/cameras/camera1/stop-recording
POST http://localhost:8000/cameras/camera2/stop-recording
# No request body required
# Response: StopRecordingResponse
# {
# "success": true,
# "message": "Recording stopped for camera1",
# "duration_seconds": 45.2
# }
###############################################################################
# CAMERA RECOVERY & DIAGNOSTICS ENDPOINTS
###############################################################################
### Test camera connection
POST http://localhost:8000/cameras/camera1/test-connection
POST http://localhost:8000/cameras/camera2/test-connection
# No request body required
# Response: CameraTestResponse
# {
# "success": true,
# "message": "Camera camera1 connection test passed",
# "camera_name": "camera1",
# "timestamp": "2025-07-28T12:00:00"
# }
###
### Reconnect camera (soft recovery)
POST http://localhost:8000/cameras/camera1/reconnect
POST http://localhost:8000/cameras/camera2/reconnect
# No request body required
# Response: CameraRecoveryResponse
# {
# "success": true,
# "message": "Camera camera1 reconnected successfully",
# "camera_name": "camera1",
# "operation": "reconnect",
# "timestamp": "2025-07-28T12:00:00"
# }
###
### Restart camera grab process
POST http://localhost:8000/cameras/camera1/restart-grab
POST http://localhost:8000/cameras/camera2/restart-grab
# Response: CameraRecoveryResponse (same structure as reconnect)
###
### Reset camera timestamp
POST http://localhost:8000/cameras/camera1/reset-timestamp
POST http://localhost:8000/cameras/camera2/reset-timestamp
# Response: CameraRecoveryResponse (same structure as reconnect)
###
### Full camera reset (hard recovery)
POST http://localhost:8000/cameras/camera1/full-reset
### Full camera reset (hard recovery)
POST http://localhost:8000/cameras/camera2/full-reset
# Response: CameraRecoveryResponse (same structure as reconnect)
###
### Reinitialize failed camera
POST http://localhost:8000/cameras/camera1/reinitialize
POST http://localhost:8000/cameras/camera2/reinitialize
# Response: CameraRecoveryResponse (same structure as reconnect)
###############################################################################
# RECORDING SESSIONS ENDPOINT
###############################################################################
### Get all recording sessions
GET http://localhost:8000/recordings
# Response: Dict[str, RecordingInfoResponse]
# {
# "rec_001": {
# "camera_name": "camera1",
# "filename": "20250728_120000_test.avi",
# "start_time": "2025-07-28T12:00:00",
# "state": "completed",
# "end_time": "2025-07-28T12:05:00",
# "file_size_bytes": 1048576,
# "frame_count": 1500,
# "duration_seconds": 300.0,
# "error_message": null
# }
# }
###############################################################################
# STORAGE ENDPOINTS
###############################################################################
### Get storage statistics
GET http://localhost:8000/storage/stats
# Response: StorageStatsResponse
# {
# "base_path": "/storage",
# "total_files": 25,
# "total_size_bytes": 52428800,
# "cameras": {
# "camera1": {
# "file_count": 15,
# "total_size_bytes": 31457280
# }
# },
# "disk_usage": {
# "total": 1000000000,
# "used": 500000000,
# "free": 500000000
# }
# }
###
### Get recording files list (with filters)
POST http://localhost:8000/storage/files
Content-Type: application/json
{
"camera_name": "camera1",
"start_date": "2025-07-25T00:00:00",
"end_date": "2025-07-28T23:59:59",
"limit": 50
}
# Request Parameters (all optional):
# - camera_name: string - Filter by specific camera
# - start_date: string (ISO format) - Filter files from this date
# - end_date: string (ISO format) - Filter files until this date
# - limit: integer (max 1000, default 100) - Maximum number of files to return
#
# Response: FileListResponse
# {
# "files": [
# {
# "filename": "20250728_120000_test.avi",
# "camera_name": "camera1",
# "file_size_bytes": 1048576,
# "created_date": "2025-07-28T12:00:00",
# "duration_seconds": 300.0
# }
# ],
# "total_count": 1
# }
###
### Get all files (no camera filter)
POST http://localhost:8000/storage/files
Content-Type: application/json
{
"limit": 100
}
###
### Cleanup old storage files
POST http://localhost:8000/storage/cleanup
Content-Type: application/json
{
"max_age_days": 7
}
# Request Parameters:
# - max_age_days: integer (optional) - Remove files older than this many days
# If not provided, uses config default (30 days)
#
# Response: CleanupResponse
# {
# "files_removed": 5,
# "bytes_freed": 10485760,
# "errors": []
# }
###############################################################################
# ERROR RESPONSES
###############################################################################
# All endpoints may return ErrorResponse on failure:
# {
# "error": "Error description",
# "details": "Additional error details",
# "timestamp": "2025-07-28T12:00:00"
# }
# Common HTTP status codes:
# - 200: Success
# - 400: Bad Request (invalid parameters)
# - 404: Not Found (camera/resource not found)
# - 500: Internal Server Error
# - 503: Service Unavailable (camera manager not available)
###############################################################################
# NOTES
###############################################################################
# 1. All timestamps are in ISO 8601 format
# 2. File sizes are in bytes
# 3. Camera names: "camera1", "camera2"
# 4. Machine names: "vibratory_conveyor", "blower_separator"
# 5. FPS behavior:
# - fps > 0: Capture at specified frame rate
# - fps = 0: Capture at MAXIMUM possible speed (no delay)
# - fps omitted: Uses camera config default
# 6. Filenames automatically get datetime prefix: YYYYMMDD_HHMMSS_filename.avi
# 7. Recovery endpoints should be used in order: test-connection → reconnect → restart-grab → full-reset → reinitialize

View File

@@ -8,33 +8,151 @@ GET http://localhost:8000/cameras/camera1/status
### ###
### Get camera2 status ### Get camera2 status
GET http://localhost:8000/cameras/camera2/status GET http://localhost:8000/cameras/camera2/status
###
### RECORDING TESTS
### Note: All filenames will automatically have datetime prefix added
### Format: YYYYMMDD_HHMMSS_filename.avi (or auto-generated if no filename)
###
### FPS Behavior:
### - fps > 0: Capture at specified frame rate
### - fps = 0: Capture at MAXIMUM possible speed (no delay between frames)
### - fps omitted: Uses camera config default (usually 3.0 fps)
### - Video files saved with 30 FPS metadata when fps=0 for proper playback
### ###
### Start recording camera1 ### Start recording camera1 (basic)
POST http://localhost:8000/cameras/camera1/start-recording POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json Content-Type: application/json
{ {
"camera_name": "camera1", "filename": "manual22_test_cam1.avi"
"filename": "manual_test_cam1.avi"
} }
### ###
### Start recording camera2 ### Start recording camera1 (with camera settings)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"filename": "test_with_settings.avi",
"exposure_ms": 2.0,
"gain": 4.0,
"fps": 0
}
###
### Start recording camera2 (basic)
POST http://localhost:8000/cameras/camera2/start-recording POST http://localhost:8000/cameras/camera2/start-recording
Content-Type: application/json Content-Type: application/json
{ {
"camera_name": "camera2",
"filename": "manual_test_cam2.avi" "filename": "manual_test_cam2.avi"
} }
### ###
### Start recording camera2 (with different settings)
POST http://localhost:8000/cameras/camera2/start-recording
Content-Type: application/json
{
"filename": "high_fps_test.avi",
"exposure_ms": 0.5,
"gain": 2.5,
"fps": 10.0
}
###
### Start recording camera1 (no filename, only settings)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"exposure_ms": 1.5,
"gain": 3.0,
"fps": 7.0
}
###
### Start recording camera1 (only filename, no settings)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"filename": "just_filename_test.avi"
}
###
### Start recording camera2 (only exposure setting)
POST http://localhost:8000/cameras/camera2/start-recording
Content-Type: application/json
{
"exposure_ms": 3.0
}
###
### Start recording camera1 (only gain setting)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"gain": 5.5
}
###
### Start recording camera2 (only fps setting)
POST http://localhost:8000/cameras/camera2/start-recording
Content-Type: application/json
{
"fps": 15.0
}
###
### Start recording camera1 (maximum fps - no delay)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{
"filename": "max_fps_test.avi",
"fps": 0
}
###
### Start recording camera2 (maximum fps with settings)
POST http://localhost:8000/cameras/camera2/start-recording
Content-Type: application/json
{
"filename": "max_fps_low_exposure.avi",
"exposure_ms": 0.1,
"gain": 1.0,
"fps": 0
}
###
### Start recording camera1 (empty body - all defaults)
POST http://localhost:8000/cameras/camera1/start-recording
Content-Type: application/json
{}
###
### Stop camera1 recording ### Stop camera1 recording
POST http://localhost:8000/cameras/camera1/stop-recording POST http://localhost:8000/cameras/camera1/stop-recording
@@ -43,6 +161,8 @@ POST http://localhost:8000/cameras/camera1/stop-recording
### Stop camera2 recording ### Stop camera2 recording
POST http://localhost:8000/cameras/camera2/stop-recording POST http://localhost:8000/cameras/camera2/stop-recording
###
### SYSTEM STATUS AND STORAGE TESTS
### ###
### Get all cameras status ### Get all cameras status
@@ -77,4 +197,112 @@ Content-Type: application/json
### ###
### Health check ### Health check
GET http://localhost:8000/health GET http://localhost:8000/health
###
### CAMERA RECOVERY AND DIAGNOSTICS TESTS
###
### These endpoints help recover cameras that have failed to initialize or lost connection.
###
### Recovery Methods (in order of severity):
### 1. test-connection: Test if camera connection is working
### 2. reconnect: Soft reconnection using CameraReConnect()
### 3. restart-grab: Restart grab process using CameraRestartGrab()
### 4. reset-timestamp: Reset camera timestamp using CameraRstTimeStamp()
### 5. full-reset: Hard reset - uninitialize and reinitialize camera
### 6. reinitialize: Complete reinitialization for cameras that never initialized
###
### Recommended troubleshooting order:
### 1. Start with test-connection to diagnose the issue
### 2. Try reconnect first (most common fix)
### 3. If reconnect fails, try restart-grab
### 4. If still failing, try full-reset
### 5. Use reinitialize only for cameras that failed initial setup
###
### Test camera1 connection
POST http://localhost:8000/cameras/camera1/test-connection
###
### Test camera2 connection
POST http://localhost:8000/cameras/camera2/test-connection
###
### Reconnect camera1 (soft recovery)
POST http://localhost:8000/cameras/camera1/reconnect
###
### Reconnect camera2 (soft recovery)
POST http://localhost:8000/cameras/camera2/reconnect
###
### Restart camera1 grab process
POST http://localhost:8000/cameras/camera1/restart-grab
###
### Restart camera2 grab process
POST http://localhost:8000/cameras/camera2/restart-grab
###
### Reset camera1 timestamp
POST http://localhost:8000/cameras/camera1/reset-timestamp
###
### Reset camera2 timestamp
POST http://localhost:8000/cameras/camera2/reset-timestamp
###
### Full reset camera1 (hard recovery - uninitialize and reinitialize)
POST http://localhost:8000/cameras/camera1/full-reset
###
### Full reset camera2 (hard recovery - uninitialize and reinitialize)
POST http://localhost:8000/cameras/camera2/full-reset
###
### Reinitialize camera1 (for cameras that failed to initialize)
POST http://localhost:8000/cameras/camera1/reinitialize
###
### Reinitialize camera2 (for cameras that failed to initialize)
POST http://localhost:8000/cameras/camera2/reinitialize
###
### RECOVERY WORKFLOW EXAMPLES
###
### Example 1: Basic troubleshooting workflow for camera1
### Step 1: Test connection
POST http://localhost:8000/cameras/camera1/test-connection
### Step 2: If test fails, try reconnect
# POST http://localhost:8000/cameras/camera1/reconnect
### Step 3: If reconnect fails, try restart grab
# POST http://localhost:8000/cameras/camera1/restart-grab
### Step 4: If still failing, try full reset
# POST http://localhost:8000/cameras/camera1/full-reset
### Step 5: If camera never initialized, try reinitialize
# POST http://localhost:8000/cameras/camera1/reinitialize
###
### Example 2: Quick recovery sequence for camera2
### Try reconnect first (most common fix)
POST http://localhost:8000/cameras/camera2/reconnect
### If that doesn't work, try full reset
# POST http://localhost:8000/cameras/camera2/full-reset

View File

@@ -17,7 +17,7 @@
}, },
"system": { "system": {
"camera_check_interval_seconds": 2, "camera_check_interval_seconds": 2,
"log_level": "INFO", "log_level": "DEBUG",
"log_file": "usda_vision_system.log", "log_file": "usda_vision_system.log",
"api_host": "0.0.0.0", "api_host": "0.0.0.0",
"api_port": 8000, "api_port": 8000,
@@ -32,7 +32,20 @@
"exposure_ms": 1.0, "exposure_ms": 1.0,
"gain": 3.5, "gain": 3.5,
"target_fps": 0, "target_fps": 0,
"enabled": true "enabled": true,
"sharpness": 120,
"contrast": 110,
"saturation": 100,
"gamma": 100,
"noise_filter_enabled": true,
"denoise_3d_enabled": false,
"auto_white_balance": true,
"color_temperature_preset": 0,
"anti_flicker_enabled": true,
"light_frequency": 1,
"bit_depth": 8,
"hdr_enabled": false,
"hdr_gain_mode": 0
}, },
{ {
"name": "camera2", "name": "camera2",
@@ -41,7 +54,20 @@
"exposure_ms": 1.0, "exposure_ms": 1.0,
"gain": 3.5, "gain": 3.5,
"target_fps": 0, "target_fps": 0,
"enabled": true "enabled": true,
"sharpness": 120,
"contrast": 110,
"saturation": 100,
"gamma": 100,
"noise_filter_enabled": true,
"denoise_3d_enabled": false,
"auto_white_balance": true,
"color_temperature_preset": 0,
"anti_flicker_enabled": true,
"light_frequency": 1,
"bit_depth": 8,
"hdr_enabled": false,
"hdr_gain_mode": 0
} }
] ]
} }

117
demo_mqtt_console.py Normal file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Demo script to show MQTT console logging in action.
This script demonstrates the enhanced MQTT logging by starting just the MQTT client
and showing the console output.
"""
import sys
import os
import time
import signal
import logging
# Add the current directory to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from usda_vision_system.core.config import Config
from usda_vision_system.core.state_manager import StateManager
from usda_vision_system.core.events import EventSystem
from usda_vision_system.core.logging_config import setup_logging
from usda_vision_system.mqtt.client import MQTTClient
def signal_handler(signum, frame):
"""Handle Ctrl+C gracefully"""
print("\n🛑 Stopping MQTT demo...")
sys.exit(0)
def main():
"""Main demo function"""
print("🚀 MQTT Console Logging Demo")
print("=" * 50)
print()
print("This demo shows enhanced MQTT console logging.")
print("You'll see colorful console output for MQTT events:")
print(" 🔗 Connection status")
print(" 📋 Topic subscriptions")
print(" 📡 Incoming messages")
print(" ⚠️ Disconnections and errors")
print()
print("Press Ctrl+C to stop the demo.")
print("=" * 50)
# Setup signal handler
signal.signal(signal.SIGINT, signal_handler)
try:
# Setup logging with INFO level for console visibility
setup_logging(log_level="INFO", log_file="mqtt_demo.log")
# Load configuration
config = Config()
# Initialize components
state_manager = StateManager()
event_system = EventSystem()
# Create MQTT client
mqtt_client = MQTTClient(config, state_manager, event_system)
print(f"\n🔧 Configuration:")
print(f" Broker: {config.mqtt.broker_host}:{config.mqtt.broker_port}")
print(f" Topics: {list(config.mqtt.topics.values())}")
print()
# Start MQTT client
print("🚀 Starting MQTT client...")
if mqtt_client.start():
print("✅ MQTT client started successfully!")
print("\n👀 Watching for MQTT messages... (Press Ctrl+C to stop)")
print("-" * 50)
# Keep running and show periodic status
start_time = time.time()
last_status_time = start_time
while True:
time.sleep(1)
# Show status every 30 seconds
current_time = time.time()
if current_time - last_status_time >= 30:
status = mqtt_client.get_status()
uptime = current_time - start_time
print(f"\n📊 Status Update (uptime: {uptime:.0f}s):")
print(f" Connected: {status['connected']}")
print(f" Messages: {status['message_count']}")
print(f" Errors: {status['error_count']}")
if status['last_message_time']:
print(f" Last Message: {status['last_message_time']}")
print("-" * 50)
last_status_time = current_time
else:
print("❌ Failed to start MQTT client")
print(" Check your MQTT broker configuration in config.json")
print(" Make sure the broker is running and accessible")
except KeyboardInterrupt:
print("\n🛑 Demo stopped by user")
except Exception as e:
print(f"\n❌ Error: {e}")
finally:
# Cleanup
try:
if 'mqtt_client' in locals():
mqtt_client.stop()
print("🔌 MQTT client stopped")
except:
pass
print("\n👋 Demo completed!")
print("\n💡 To run the full system with this enhanced logging:")
print(" python main.py")
if __name__ == "__main__":
main()

234
mqtt_publisher_test.py Normal file
View File

@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
MQTT Publisher Test Script for USDA Vision Camera System
This script allows you to manually publish test messages to the MQTT topics
to simulate machine state changes for testing purposes.
Usage:
python mqtt_publisher_test.py
The script provides an interactive menu to:
1. Send 'on' state to vibratory conveyor
2. Send 'off' state to vibratory conveyor
3. Send 'on' state to blower separator
4. Send 'off' state to blower separator
5. Send custom message
"""
import paho.mqtt.client as mqtt
import time
import sys
from datetime import datetime
# MQTT Configuration (matching your system config)
MQTT_BROKER_HOST = "192.168.1.110"
MQTT_BROKER_PORT = 1883
MQTT_USERNAME = None # Set if your broker requires authentication
MQTT_PASSWORD = None # Set if your broker requires authentication
# Topics (from your config.json)
MQTT_TOPICS = {
"vibratory_conveyor": "vision/vibratory_conveyor/state",
"blower_separator": "vision/blower_separator/state"
}
class MQTTPublisher:
def __init__(self):
self.client = None
self.connected = False
def setup_client(self):
"""Setup MQTT client"""
try:
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
self.client.on_connect = self.on_connect
self.client.on_disconnect = self.on_disconnect
self.client.on_publish = self.on_publish
if MQTT_USERNAME and MQTT_PASSWORD:
self.client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
return True
except Exception as e:
print(f"❌ Error setting up MQTT client: {e}")
return False
def connect(self):
"""Connect to MQTT broker"""
try:
print(f"🔗 Connecting to MQTT broker at {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}...")
self.client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, 60)
self.client.loop_start() # Start background loop
# Wait for connection
timeout = 10
start_time = time.time()
while not self.connected and (time.time() - start_time) < timeout:
time.sleep(0.1)
return self.connected
except Exception as e:
print(f"❌ Failed to connect to MQTT broker: {e}")
return False
def disconnect(self):
"""Disconnect from MQTT broker"""
if self.client:
self.client.loop_stop()
self.client.disconnect()
def on_connect(self, client, userdata, flags, rc):
"""Callback when client connects"""
if rc == 0:
self.connected = True
print(f"✅ Connected to MQTT broker successfully!")
else:
self.connected = False
print(f"❌ Connection failed with return code {rc}")
def on_disconnect(self, client, userdata, rc):
"""Callback when client disconnects"""
self.connected = False
print(f"🔌 Disconnected from MQTT broker")
def on_publish(self, client, userdata, mid):
"""Callback when message is published"""
print(f"📤 Message published successfully (mid: {mid})")
def publish_message(self, topic, payload):
"""Publish a message to a topic"""
if not self.connected:
print("❌ Not connected to MQTT broker")
return False
try:
timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3]
print(f"📡 [{timestamp}] Publishing message:")
print(f" 📍 Topic: {topic}")
print(f" 📄 Payload: '{payload}'")
result = self.client.publish(topic, payload)
if result.rc == mqtt.MQTT_ERR_SUCCESS:
print(f"✅ Message queued for publishing")
return True
else:
print(f"❌ Failed to publish message (error: {result.rc})")
return False
except Exception as e:
print(f"❌ Error publishing message: {e}")
return False
def show_menu(self):
"""Show interactive menu"""
print("\n" + "=" * 50)
print("🎛️ MQTT PUBLISHER TEST MENU")
print("=" * 50)
print("1. Send 'on' to vibratory conveyor")
print("2. Send 'off' to vibratory conveyor")
print("3. Send 'on' to blower separator")
print("4. Send 'off' to blower separator")
print("5. Send custom message")
print("6. Show current topics")
print("0. Exit")
print("-" * 50)
def handle_menu_choice(self, choice):
"""Handle menu selection"""
if choice == "1":
self.publish_message(MQTT_TOPICS["vibratory_conveyor"], "on")
elif choice == "2":
self.publish_message(MQTT_TOPICS["vibratory_conveyor"], "off")
elif choice == "3":
self.publish_message(MQTT_TOPICS["blower_separator"], "on")
elif choice == "4":
self.publish_message(MQTT_TOPICS["blower_separator"], "off")
elif choice == "5":
self.custom_message()
elif choice == "6":
self.show_topics()
elif choice == "0":
return False
else:
print("❌ Invalid choice. Please try again.")
return True
def custom_message(self):
"""Send custom message"""
print("\n📝 Custom Message")
print("Available topics:")
for i, (name, topic) in enumerate(MQTT_TOPICS.items(), 1):
print(f" {i}. {name}: {topic}")
try:
topic_choice = input("Select topic (1-2): ").strip()
if topic_choice == "1":
topic = MQTT_TOPICS["vibratory_conveyor"]
elif topic_choice == "2":
topic = MQTT_TOPICS["blower_separator"]
else:
print("❌ Invalid topic choice")
return
payload = input("Enter message payload: ").strip()
if payload:
self.publish_message(topic, payload)
else:
print("❌ Empty payload, message not sent")
except KeyboardInterrupt:
print("\n❌ Cancelled")
def show_topics(self):
"""Show configured topics"""
print("\n📋 Configured Topics:")
for name, topic in MQTT_TOPICS.items():
print(f" 🏭 {name}: {topic}")
def run(self):
"""Main interactive loop"""
print("📤 MQTT Publisher Test")
print("=" * 50)
print(f"🎯 Broker: {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}")
if not self.setup_client():
return False
if not self.connect():
print("❌ Failed to connect to MQTT broker")
return False
try:
while True:
self.show_menu()
choice = input("Enter your choice: ").strip()
if not self.handle_menu_choice(choice):
break
except KeyboardInterrupt:
print("\n\n🛑 Interrupted by user")
except Exception as e:
print(f"\n❌ Error: {e}")
finally:
self.disconnect()
print("👋 Goodbye!")
return True
def main():
"""Main function"""
publisher = MQTTPublisher()
try:
publisher.run()
except Exception as e:
print(f"❌ Unexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

242
mqtt_test.py Normal file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""
MQTT Test Script for USDA Vision Camera System
This script tests MQTT message reception by connecting to the broker
and listening for messages on the configured topics.
Usage:
python mqtt_test.py
The script will:
1. Connect to the MQTT broker
2. Subscribe to all configured topics
3. Display received messages with timestamps
4. Show connection status and statistics
"""
import paho.mqtt.client as mqtt
import time
import json
import signal
import sys
from datetime import datetime
from typing import Dict, Optional
# MQTT Configuration (matching your system config)
MQTT_BROKER_HOST = "192.168.1.110"
MQTT_BROKER_PORT = 1883
MQTT_USERNAME = None # Set if your broker requires authentication
MQTT_PASSWORD = None # Set if your broker requires authentication
# Topics to monitor (from your config.json)
MQTT_TOPICS = {
"vibratory_conveyor": "vision/vibratory_conveyor/state",
"blower_separator": "vision/blower_separator/state"
}
class MQTTTester:
def __init__(self):
self.client: Optional[mqtt.Client] = None
self.connected = False
self.message_count = 0
self.start_time = None
self.last_message_time = None
self.received_messages = []
def setup_client(self):
"""Setup MQTT client with callbacks"""
try:
# Create MQTT client
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
# Set callbacks
self.client.on_connect = self.on_connect
self.client.on_disconnect = self.on_disconnect
self.client.on_message = self.on_message
self.client.on_subscribe = self.on_subscribe
# Set authentication if provided
if MQTT_USERNAME and MQTT_PASSWORD:
self.client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
print(f"🔐 Using authentication: {MQTT_USERNAME}")
return True
except Exception as e:
print(f"❌ Error setting up MQTT client: {e}")
return False
def connect(self):
"""Connect to MQTT broker"""
try:
print(f"🔗 Connecting to MQTT broker at {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}...")
self.client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, 60)
return True
except Exception as e:
print(f"❌ Failed to connect to MQTT broker: {e}")
return False
def on_connect(self, client, userdata, flags, rc):
"""Callback when client connects to broker"""
if rc == 0:
self.connected = True
self.start_time = datetime.now()
print(f"✅ Successfully connected to MQTT broker!")
print(f"📅 Connection time: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
print()
# Subscribe to all topics
print("📋 Subscribing to topics:")
for machine_name, topic in MQTT_TOPICS.items():
result, mid = client.subscribe(topic)
if result == mqtt.MQTT_ERR_SUCCESS:
print(f"{machine_name}: {topic}")
else:
print(f"{machine_name}: {topic} (error: {result})")
print()
print("🎧 Listening for MQTT messages...")
print(" (Manually turn machines on/off to trigger messages)")
print(" (Press Ctrl+C to stop)")
print("-" * 60)
else:
self.connected = False
print(f"❌ Connection failed with return code {rc}")
print(" Return codes:")
print(" 0: Connection successful")
print(" 1: Connection refused - incorrect protocol version")
print(" 2: Connection refused - invalid client identifier")
print(" 3: Connection refused - server unavailable")
print(" 4: Connection refused - bad username or password")
print(" 5: Connection refused - not authorised")
def on_disconnect(self, client, userdata, rc):
"""Callback when client disconnects from broker"""
self.connected = False
if rc != 0:
print(f"🔌 Unexpected disconnection from MQTT broker (code: {rc})")
else:
print(f"🔌 Disconnected from MQTT broker")
def on_subscribe(self, client, userdata, mid, granted_qos):
"""Callback when subscription is confirmed"""
print(f"📋 Subscription confirmed (mid: {mid}, QoS: {granted_qos})")
def on_message(self, client, userdata, msg):
"""Callback when a message is received"""
try:
# Decode message
topic = msg.topic
payload = msg.payload.decode("utf-8").strip()
timestamp = datetime.now()
# Update statistics
self.message_count += 1
self.last_message_time = timestamp
# Find machine name
machine_name = "unknown"
for name, configured_topic in MQTT_TOPICS.items():
if topic == configured_topic:
machine_name = name
break
# Store message
message_data = {
"timestamp": timestamp,
"topic": topic,
"machine": machine_name,
"payload": payload,
"message_number": self.message_count
}
self.received_messages.append(message_data)
# Display message
time_str = timestamp.strftime('%H:%M:%S.%f')[:-3] # Include milliseconds
print(f"📡 [{time_str}] Message #{self.message_count}")
print(f" 🏭 Machine: {machine_name}")
print(f" 📍 Topic: {topic}")
print(f" 📄 Payload: '{payload}'")
print(f" 📊 Total messages: {self.message_count}")
print("-" * 60)
except Exception as e:
print(f"❌ Error processing message: {e}")
def show_statistics(self):
"""Show connection and message statistics"""
print("\n" + "=" * 60)
print("📊 MQTT TEST STATISTICS")
print("=" * 60)
if self.start_time:
runtime = datetime.now() - self.start_time
print(f"⏱️ Runtime: {runtime}")
print(f"🔗 Connected: {'Yes' if self.connected else 'No'}")
print(f"📡 Messages received: {self.message_count}")
if self.last_message_time:
print(f"🕐 Last message: {self.last_message_time.strftime('%Y-%m-%d %H:%M:%S')}")
if self.received_messages:
print(f"\n📋 Message Summary:")
for msg in self.received_messages[-5:]: # Show last 5 messages
time_str = msg["timestamp"].strftime('%H:%M:%S')
print(f" [{time_str}] {msg['machine']}: {msg['payload']}")
print("=" * 60)
def run(self):
"""Main test loop"""
print("🧪 MQTT Message Reception Test")
print("=" * 60)
print(f"🎯 Broker: {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}")
print(f"📋 Topics: {list(MQTT_TOPICS.values())}")
print()
# Setup signal handler for graceful shutdown
def signal_handler(sig, frame):
print(f"\n\n🛑 Received interrupt signal, shutting down...")
self.show_statistics()
if self.client and self.connected:
self.client.disconnect()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
# Setup and connect
if not self.setup_client():
return False
if not self.connect():
return False
# Start the client loop
try:
self.client.loop_forever()
except KeyboardInterrupt:
pass
except Exception as e:
print(f"❌ Error in main loop: {e}")
return True
def main():
"""Main function"""
tester = MQTTTester()
try:
success = tester.run()
if not success:
print("❌ Test failed")
sys.exit(1)
except Exception as e:
print(f"❌ Unexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -18,7 +18,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 1, "execution_count": 26,
"id": "imports", "id": "imports",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -42,7 +42,7 @@
"from datetime import datetime\n", "from datetime import datetime\n",
"\n", "\n",
"# Add the python demo directory to path to import mvsdk\n", "# Add the python demo directory to path to import mvsdk\n",
"sys.path.append('./python demo')\n", "sys.path.append('../python demo')\n",
"import mvsdk\n", "import mvsdk\n",
"\n", "\n",
"print(\"Libraries imported successfully!\")\n", "print(\"Libraries imported successfully!\")\n",
@@ -51,7 +51,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 2, "execution_count": 27,
"id": "error-codes", "id": "error-codes",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -88,7 +88,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 3, "execution_count": 28,
"id": "status-functions", "id": "status-functions",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -215,7 +215,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 4, "execution_count": 29,
"id": "test-capture-availability", "id": "test-capture-availability",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -375,7 +375,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 5, "execution_count": 30,
"id": "comprehensive-check", "id": "comprehensive-check",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -408,7 +408,7 @@
"FINAL RESULTS:\n", "FINAL RESULTS:\n",
"Camera Available: False\n", "Camera Available: False\n",
"Capture Ready: False\n", "Capture Ready: False\n",
"Status: (6, 'AVAILABLE')\n", "Status: (42, 'AVAILABLE')\n",
"==================================================\n" "==================================================\n"
] ]
} }
@@ -455,7 +455,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 6, "execution_count": 31,
"id": "status-check-function", "id": "status-check-function",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [

View File

@@ -18,9 +18,19 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 1,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"✅ All imports successful!\n",
"OpenCV version: 4.11.0\n",
"NumPy version: 2.3.2\n"
]
}
],
"source": [ "source": [
"import cv2\n", "import cv2\n",
"import numpy as np\n", "import numpy as np\n",
@@ -50,9 +60,17 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 2,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"✅ Utility functions loaded!\n"
]
}
],
"source": [ "source": [
"def display_image(image, title=\"Image\", figsize=(10, 8)):\n", "def display_image(image, title=\"Image\", figsize=(10, 8)):\n",
" \"\"\"Display image inline in Jupyter notebook\"\"\"\n", " \"\"\"Display image inline in Jupyter notebook\"\"\"\n",
@@ -130,9 +148,22 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 3,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Storage directory exists: True\n",
"Storage directory writable: True\n",
"📁 Directory ready: /storage/test_images\n",
"📁 Directory ready: /storage/test_videos\n",
"📁 Directory ready: /storage/camera1\n",
"📁 Directory ready: /storage/camera2\n"
]
}
],
"source": [ "source": [
"# Check storage directory\n", "# Check storage directory\n",
"storage_path = Path(\"/storage\")\n", "storage_path = Path(\"/storage\")\n",
@@ -155,9 +186,92 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 4,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"🔍 Scanning for available cameras...\n",
"❌ No cameras found\n",
"\n",
"📊 Summary: Found 0 camera(s): []\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"[ WARN:0@9.977] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video0): can't open camera by index\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.977] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.977] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video1): can't open camera by index\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.977] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.977] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video2): can't open camera by index\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.977] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.977] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video3): can't open camera by index\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.977] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.977] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.977] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video4): can't open camera by index\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.978] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.978] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video5): can't open camera by index\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.978] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.978] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video6): can't open camera by index\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.978] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.978] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video7): can't open camera by index\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.978] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.978] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video8): can't open camera by index\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.978] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.978] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video9): can't open camera by index\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.978] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@9.978] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video10): can't open camera by index\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.978] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@9.979] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@9.979] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@9.979] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n"
]
}
],
"source": [ "source": [
"# Scan for cameras\n", "# Scan for cameras\n",
"cameras = list_available_cameras()\n", "cameras = list_available_cameras()\n",
@@ -173,9 +287,41 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 5,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"🔧 Testing camera 0...\n",
" Trying Default backend...\n",
" ❌ Default backend failed to open\n",
" Trying GStreamer backend...\n",
" ❌ GStreamer backend failed to open\n",
" Trying V4L2 backend...\n",
" ❌ V4L2 backend failed to open\n",
" Trying FFmpeg backend...\n",
" ❌ FFmpeg backend failed to open\n",
"❌ Camera 0 not accessible with any backend\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"[ WARN:0@27.995] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video0): can't open camera by index\n",
"[ WARN:0@27.995] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@27.995] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ WARN:0@27.995] global obsensor_stream_channel_v4l2.cpp:82 xioctl ioctl: fd=-1, req=-2140645888\n",
"[ WARN:0@27.995] global obsensor_stream_channel_v4l2.cpp:138 queryUvcDeviceInfoList ioctl error return: 9\n",
"[ERROR:0@27.995] global obsensor_uvc_stream_channel.cpp:158 getStreamChannelGroup Camera index out of range\n",
"[ WARN:0@27.996] global cap_v4l.cpp:913 open VIDEOIO(V4L2:/dev/video0): can't open camera by index\n",
"[ WARN:0@27.996] global cap.cpp:478 open VIDEOIO(V4L2): backend is generally available but can't be used to capture by index\n",
"[ WARN:0@27.996] global cap.cpp:478 open VIDEOIO(FFMPEG): backend is generally available but can't be used to capture by index\n"
]
}
],
"source": [ "source": [
"# Test a specific camera (change camera_id as needed)\n", "# Test a specific camera (change camera_id as needed)\n",
"camera_id = 0 # Change this to test different cameras\n", "camera_id = 0 # Change this to test different cameras\n",
@@ -327,9 +473,9 @@
], ],
"metadata": { "metadata": {
"kernelspec": { "kernelspec": {
"display_name": "usda-vision-cameras", "display_name": "USDA-vision-cameras",
"language": "python", "language": "python",
"name": "usda-vision-cameras" "name": "python3"
}, },
"language_info": { "language_info": {
"codemirror_mode": { "codemirror_mode": {
@@ -341,7 +487,7 @@
"name": "python", "name": "python",
"nbconvert_exporter": "python", "nbconvert_exporter": "python",
"pygments_lexer": "ipython3", "pygments_lexer": "ipython3",
"version": "3.11.0" "version": "3.11.2"
} }
}, },
"nbformat": 4, "nbformat": 4,

View File

@@ -17,4 +17,5 @@ dependencies = [
"websockets>=12.0", "websockets>=12.0",
"requests>=2.31.0", "requests>=2.31.0",
"pytz>=2023.3", "pytz>=2023.3",
"ipykernel>=6.30.0",
] ]

173
test_api_changes.py Normal file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Test script to verify the API changes for camera settings and filename handling.
"""
import requests
import json
import time
from datetime import datetime
# API base URL
BASE_URL = "http://localhost:8000"
def test_api_endpoint(endpoint, method="GET", data=None):
"""Test an API endpoint and return the response"""
url = f"{BASE_URL}{endpoint}"
try:
if method == "GET":
response = requests.get(url)
elif method == "POST":
response = requests.post(url, json=data, headers={"Content-Type": "application/json"})
print(f"\n{method} {endpoint}")
print(f"Status: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"Response: {json.dumps(result, indent=2)}")
return result
else:
print(f"Error: {response.text}")
return None
except requests.exceptions.ConnectionError:
print(f"Error: Could not connect to {url}")
print("Make sure the API server is running with: python main.py")
return None
except Exception as e:
print(f"Error: {e}")
return None
def test_camera_recording_with_settings():
"""Test camera recording with new settings parameters"""
print("=" * 60)
print("Testing Camera Recording API with New Settings")
print("=" * 60)
# Test 1: Basic recording without settings
print("\n1. Testing basic recording (no settings)")
basic_request = {
"camera_name": "camera1",
"filename": "test_basic.avi"
}
result = test_api_endpoint("/cameras/camera1/start-recording", "POST", basic_request)
if result and result.get("success"):
print("✅ Basic recording started successfully")
print(f" Filename: {result.get('filename')}")
# Stop recording
time.sleep(2)
test_api_endpoint("/cameras/camera1/stop-recording", "POST")
else:
print("❌ Basic recording failed")
# Test 2: Recording with camera settings
print("\n2. Testing recording with camera settings")
settings_request = {
"camera_name": "camera1",
"filename": "test_with_settings.avi",
"exposure_ms": 2.0,
"gain": 4.0,
"fps": 5.0
}
result = test_api_endpoint("/cameras/camera1/start-recording", "POST", settings_request)
if result and result.get("success"):
print("✅ Recording with settings started successfully")
print(f" Filename: {result.get('filename')}")
# Stop recording
time.sleep(2)
test_api_endpoint("/cameras/camera1/stop-recording", "POST")
else:
print("❌ Recording with settings failed")
# Test 3: Recording with only settings (no filename)
print("\n3. Testing recording with settings only (no filename)")
settings_only_request = {
"camera_name": "camera1",
"exposure_ms": 1.5,
"gain": 3.0,
"fps": 7.0
}
result = test_api_endpoint("/cameras/camera1/start-recording", "POST", settings_only_request)
if result and result.get("success"):
print("✅ Recording with settings only started successfully")
print(f" Filename: {result.get('filename')}")
# Stop recording
time.sleep(2)
test_api_endpoint("/cameras/camera1/stop-recording", "POST")
else:
print("❌ Recording with settings only failed")
# Test 4: Test filename datetime prefix
print("\n4. Testing filename datetime prefix")
timestamp_before = datetime.now().strftime("%Y%m%d_%H%M")
filename_test_request = {
"camera_name": "camera1",
"filename": "my_custom_name.avi"
}
result = test_api_endpoint("/cameras/camera1/start-recording", "POST", filename_test_request)
if result and result.get("success"):
returned_filename = result.get('filename', '')
print(f" Original filename: my_custom_name.avi")
print(f" Returned filename: {returned_filename}")
# Check if datetime prefix was added
if timestamp_before in returned_filename and "my_custom_name.avi" in returned_filename:
print("✅ Datetime prefix correctly added to filename")
else:
print("❌ Datetime prefix not properly added")
# Stop recording
time.sleep(2)
test_api_endpoint("/cameras/camera1/stop-recording", "POST")
else:
print("❌ Filename test failed")
def test_system_status():
"""Test basic system status to ensure API is working"""
print("\n" + "=" * 60)
print("Testing System Status")
print("=" * 60)
# Test system status
result = test_api_endpoint("/system/status")
if result:
print("✅ System status API working")
print(f" System started: {result.get('system_started')}")
print(f" MQTT connected: {result.get('mqtt_connected')}")
else:
print("❌ System status API failed")
# Test camera status
result = test_api_endpoint("/cameras")
if result:
print("✅ Camera status API working")
for camera_name, camera_info in result.items():
print(f" {camera_name}: {camera_info.get('status')}")
else:
print("❌ Camera status API failed")
if __name__ == "__main__":
print("USDA Vision Camera System - API Changes Test")
print("This script tests the new camera settings parameters and filename handling")
print("\nMake sure the system is running with: python main.py")
# Test system status first
test_system_status()
# Test camera recording with new features
test_camera_recording_with_settings()
print("\n" + "=" * 60)
print("Test completed!")
print("=" * 60)

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Test script for camera recovery API endpoints.
This script tests the new camera recovery functionality without requiring actual cameras.
"""
import requests
import json
import time
from typing import Dict, Any
# API base URL
BASE_URL = "http://localhost:8000"
def test_endpoint(method: str, endpoint: str, data: Dict[Any, Any] = None) -> Dict[Any, Any]:
"""Test an API endpoint and return the response"""
url = f"{BASE_URL}{endpoint}"
try:
if method.upper() == "GET":
response = requests.get(url, timeout=10)
elif method.upper() == "POST":
response = requests.post(url, json=data or {}, timeout=10)
else:
raise ValueError(f"Unsupported method: {method}")
print(f"\n{method} {endpoint}")
print(f"Status: {response.status_code}")
if response.headers.get('content-type', '').startswith('application/json'):
result = response.json()
print(f"Response: {json.dumps(result, indent=2)}")
return result
else:
print(f"Response: {response.text}")
return {"text": response.text}
except requests.exceptions.ConnectionError:
print(f"❌ Connection failed - API server not running at {BASE_URL}")
return {"error": "connection_failed"}
except requests.exceptions.Timeout:
print(f"❌ Request timeout")
return {"error": "timeout"}
except Exception as e:
print(f"❌ Error: {e}")
return {"error": str(e)}
def main():
"""Test camera recovery API endpoints"""
print("🔧 Testing Camera Recovery API Endpoints")
print("=" * 50)
# Test basic endpoints first
print("\n📋 BASIC API TESTS")
test_endpoint("GET", "/health")
test_endpoint("GET", "/cameras")
# Test camera recovery endpoints
print("\n🔧 CAMERA RECOVERY TESTS")
camera_names = ["camera1", "camera2"]
for camera_name in camera_names:
print(f"\n--- Testing {camera_name} ---")
# Test connection
test_endpoint("POST", f"/cameras/{camera_name}/test-connection")
# Test reconnect
test_endpoint("POST", f"/cameras/{camera_name}/reconnect")
# Test restart grab
test_endpoint("POST", f"/cameras/{camera_name}/restart-grab")
# Test reset timestamp
test_endpoint("POST", f"/cameras/{camera_name}/reset-timestamp")
# Test full reset
test_endpoint("POST", f"/cameras/{camera_name}/full-reset")
# Test reinitialize
test_endpoint("POST", f"/cameras/{camera_name}/reinitialize")
time.sleep(0.5) # Small delay between tests
print("\n✅ Camera recovery API tests completed!")
print("\nNote: Some operations may fail if cameras are not connected,")
print("but the API endpoints should respond with proper error messages.")
if __name__ == "__main__":
main()

131
test_max_fps.py Normal file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
Test script to demonstrate maximum FPS capture functionality.
"""
import requests
import json
import time
from datetime import datetime
BASE_URL = "http://localhost:8000"
def test_fps_modes():
"""Test different FPS modes to demonstrate the functionality"""
print("=" * 60)
print("Testing Maximum FPS Capture Functionality")
print("=" * 60)
# Test configurations
test_configs = [
{
"name": "Normal FPS (3.0)",
"data": {
"filename": "normal_fps_test.avi",
"exposure_ms": 1.0,
"gain": 3.0,
"fps": 3.0
}
},
{
"name": "High FPS (10.0)",
"data": {
"filename": "high_fps_test.avi",
"exposure_ms": 0.5,
"gain": 2.0,
"fps": 10.0
}
},
{
"name": "Maximum FPS (fps=0)",
"data": {
"filename": "max_fps_test.avi",
"exposure_ms": 0.1, # Very short exposure for max speed
"gain": 1.0, # Low gain to avoid overexposure
"fps": 0 # Maximum speed - no delay
}
},
{
"name": "Default FPS (omitted)",
"data": {
"filename": "default_fps_test.avi",
"exposure_ms": 1.0,
"gain": 3.0
# fps omitted - uses camera config default
}
}
]
for i, config in enumerate(test_configs, 1):
print(f"\n{i}. Testing {config['name']}")
print("-" * 40)
# Start recording
try:
response = requests.post(
f"{BASE_URL}/cameras/camera1/start-recording",
json=config['data'],
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
result = response.json()
if result.get('success'):
print(f"✅ Recording started successfully")
print(f" Filename: {result.get('filename')}")
print(f" Settings: {json.dumps(config['data'], indent=6)}")
# Record for a short time
print(f" Recording for 3 seconds...")
time.sleep(3)
# Stop recording
stop_response = requests.post(f"{BASE_URL}/cameras/camera1/stop-recording")
if stop_response.status_code == 200:
stop_result = stop_response.json()
if stop_result.get('success'):
print(f"✅ Recording stopped successfully")
if 'duration_seconds' in stop_result:
print(f" Duration: {stop_result['duration_seconds']:.1f}s")
else:
print(f"❌ Failed to stop recording: {stop_result.get('message')}")
else:
print(f"❌ Stop request failed: {stop_response.status_code}")
else:
print(f"❌ Recording failed: {result.get('message')}")
else:
print(f"❌ Request failed: {response.status_code} - {response.text}")
except requests.exceptions.ConnectionError:
print(f"❌ Could not connect to {BASE_URL}")
print("Make sure the API server is running with: python main.py")
break
except Exception as e:
print(f"❌ Error: {e}")
# Wait between tests
if i < len(test_configs):
print(" Waiting 2 seconds before next test...")
time.sleep(2)
print("\n" + "=" * 60)
print("FPS Test Summary:")
print("=" * 60)
print("• fps > 0: Controlled frame rate with sleep delay")
print("• fps = 0: MAXIMUM speed capture (no delay between frames)")
print("• fps omitted: Uses camera config default")
print("• Video files with fps=0 are saved with 30 FPS metadata")
print("• Actual capture rate with fps=0 depends on:")
print(" - Camera hardware capabilities")
print(" - Exposure time (shorter = faster)")
print(" - Processing overhead")
print("=" * 60)
if __name__ == "__main__":
print("USDA Vision Camera System - Maximum FPS Test")
print("This script demonstrates fps=0 for maximum capture speed")
print("\nMake sure the system is running with: python main.py")
test_fps_modes()

168
test_mqtt_events_api.py Normal file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Test script for MQTT events API endpoint
This script tests the new MQTT events history functionality by:
1. Starting the system components
2. Simulating MQTT messages
3. Testing the API endpoint to retrieve events
"""
import asyncio
import time
import requests
import json
from datetime import datetime
# Test configuration
API_BASE_URL = "http://localhost:8000"
MQTT_EVENTS_ENDPOINT = f"{API_BASE_URL}/mqtt/events"
def test_api_endpoint():
"""Test the MQTT events API endpoint"""
print("🧪 Testing MQTT Events API Endpoint")
print("=" * 50)
try:
# Test basic endpoint
print("📡 Testing GET /mqtt/events (default limit=5)")
response = requests.get(MQTT_EVENTS_ENDPOINT)
if response.status_code == 200:
data = response.json()
print(f"✅ API Response successful")
print(f"📊 Total events: {data.get('total_events', 0)}")
print(f"📋 Events returned: {len(data.get('events', []))}")
if data.get('events'):
print(f"🕐 Last updated: {data.get('last_updated')}")
print("\n📝 Recent events:")
for i, event in enumerate(data['events'], 1):
timestamp = datetime.fromisoformat(event['timestamp']).strftime('%H:%M:%S')
print(f" {i}. [{timestamp}] {event['machine_name']}: {event['payload']} -> {event['normalized_state']}")
else:
print("📭 No events found")
else:
print(f"❌ API Error: {response.status_code}")
print(f" Response: {response.text}")
except requests.exceptions.ConnectionError:
print("❌ Connection Error: API server not running")
print(" Start the system first: python -m usda_vision_system.main")
except Exception as e:
print(f"❌ Error: {e}")
print()
# Test with custom limit
try:
print("📡 Testing GET /mqtt/events?limit=10")
response = requests.get(f"{MQTT_EVENTS_ENDPOINT}?limit=10")
if response.status_code == 200:
data = response.json()
print(f"✅ API Response successful")
print(f"📋 Events returned: {len(data.get('events', []))}")
else:
print(f"❌ API Error: {response.status_code}")
except Exception as e:
print(f"❌ Error: {e}")
def test_system_status():
"""Test system status to verify API is running"""
print("🔍 Checking System Status")
print("=" * 50)
try:
response = requests.get(f"{API_BASE_URL}/system/status")
if response.status_code == 200:
data = response.json()
print(f"✅ System Status: {'Running' if data.get('system_started') else 'Not Started'}")
print(f"🔗 MQTT Connected: {'Yes' if data.get('mqtt_connected') else 'No'}")
print(f"📡 Last MQTT Message: {data.get('last_mqtt_message', 'None')}")
print(f"⏱️ Uptime: {data.get('uptime_seconds', 0):.1f} seconds")
return True
else:
print(f"❌ System Status Error: {response.status_code}")
return False
except requests.exceptions.ConnectionError:
print("❌ Connection Error: API server not running")
print(" Start the system first: python -m usda_vision_system.main")
return False
except Exception as e:
print(f"❌ Error: {e}")
return False
def test_mqtt_status():
"""Test MQTT status"""
print("📡 Checking MQTT Status")
print("=" * 50)
try:
response = requests.get(f"{API_BASE_URL}/mqtt/status")
if response.status_code == 200:
data = response.json()
print(f"🔗 MQTT Connected: {'Yes' if data.get('connected') else 'No'}")
print(f"🏠 Broker: {data.get('broker_host')}:{data.get('broker_port')}")
print(f"📋 Subscribed Topics: {len(data.get('subscribed_topics', []))}")
print(f"📊 Message Count: {data.get('message_count', 0)}")
print(f"❌ Error Count: {data.get('error_count', 0)}")
if data.get('subscribed_topics'):
print("📍 Topics:")
for topic in data['subscribed_topics']:
print(f" - {topic}")
return True
else:
print(f"❌ MQTT Status Error: {response.status_code}")
return False
except Exception as e:
print(f"❌ Error: {e}")
return False
def main():
"""Main test function"""
print("🧪 MQTT Events API Test")
print("=" * 60)
print(f"🎯 API Base URL: {API_BASE_URL}")
print(f"📡 Events Endpoint: {MQTT_EVENTS_ENDPOINT}")
print()
# Test system status first
if not test_system_status():
print("\n❌ System not running. Please start the system first:")
print(" python -m usda_vision_system.main")
return
print()
# Test MQTT status
if not test_mqtt_status():
print("\n❌ MQTT not available")
return
print()
# Test the events API
test_api_endpoint()
print("\n" + "=" * 60)
print("🎯 Test Instructions:")
print("1. Make sure the system is running")
print("2. Turn machines on/off to generate MQTT events")
print("3. Run this test again to see the events")
print("4. Check the admin dashboard to see events displayed")
print()
print("📋 API Usage:")
print(f" GET {MQTT_EVENTS_ENDPOINT}")
print(f" GET {MQTT_EVENTS_ENDPOINT}?limit=10")
if __name__ == "__main__":
main()

117
test_mqtt_logging.py Normal file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Test script to demonstrate enhanced MQTT logging and API endpoints.
This script shows:
1. Enhanced console logging for MQTT events
2. New MQTT status API endpoint
3. Machine status API endpoint
"""
import sys
import os
import time
import requests
import json
from datetime import datetime
# Add the current directory to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def test_api_endpoints():
"""Test the API endpoints for MQTT and machine status"""
base_url = "http://localhost:8000"
print("🧪 Testing API Endpoints...")
print("=" * 50)
# Test system status
try:
print("\n📊 System Status:")
response = requests.get(f"{base_url}/system/status", timeout=5)
if response.status_code == 200:
data = response.json()
print(f" System Started: {data.get('system_started')}")
print(f" MQTT Connected: {data.get('mqtt_connected')}")
print(f" Last MQTT Message: {data.get('last_mqtt_message')}")
print(f" Active Recordings: {data.get('active_recordings')}")
print(f" Total Recordings: {data.get('total_recordings')}")
else:
print(f" ❌ Error: {response.status_code}")
except Exception as e:
print(f" ❌ Connection Error: {e}")
# Test MQTT status
try:
print("\n📡 MQTT Status:")
response = requests.get(f"{base_url}/mqtt/status", timeout=5)
if response.status_code == 200:
data = response.json()
print(f" Connected: {data.get('connected')}")
print(f" Broker: {data.get('broker_host')}:{data.get('broker_port')}")
print(f" Message Count: {data.get('message_count')}")
print(f" Error Count: {data.get('error_count')}")
print(f" Last Message: {data.get('last_message_time')}")
print(f" Uptime: {data.get('uptime_seconds'):.1f}s" if data.get('uptime_seconds') else " Uptime: N/A")
print(f" Subscribed Topics:")
for topic in data.get('subscribed_topics', []):
print(f" - {topic}")
else:
print(f" ❌ Error: {response.status_code}")
except Exception as e:
print(f" ❌ Connection Error: {e}")
# Test machine status
try:
print("\n🏭 Machine Status:")
response = requests.get(f"{base_url}/machines", timeout=5)
if response.status_code == 200:
data = response.json()
if data:
for machine_name, machine_info in data.items():
print(f" {machine_name}:")
print(f" State: {machine_info.get('state')}")
print(f" Last Updated: {machine_info.get('last_updated')}")
print(f" Last Message: {machine_info.get('last_message')}")
print(f" MQTT Topic: {machine_info.get('mqtt_topic')}")
else:
print(" No machines found")
else:
print(f" ❌ Error: {response.status_code}")
except Exception as e:
print(f" ❌ Connection Error: {e}")
def main():
"""Main test function"""
print("🔍 MQTT Logging and API Test")
print("=" * 50)
print()
print("This script tests the enhanced MQTT logging and new API endpoints.")
print("Make sure the USDA Vision System is running before testing.")
print()
# Wait a moment
time.sleep(1)
# Test API endpoints
test_api_endpoints()
print("\n" + "=" * 50)
print("✅ Test completed!")
print()
print("📝 What to expect when running the system:")
print(" 🔗 MQTT CONNECTED: [broker_host:port]")
print(" 📋 MQTT SUBSCRIBED: [machine] → [topic]")
print(" 📡 MQTT MESSAGE: [machine] → [payload]")
print(" ⚠️ MQTT DISCONNECTED: [reason]")
print()
print("🌐 API Endpoints available:")
print(" GET /system/status - Overall system status")
print(" GET /mqtt/status - MQTT client status and statistics")
print(" GET /machines - All machine states from MQTT")
print(" GET /cameras - Camera statuses")
print()
print("💡 To see live MQTT logs, run: python main.py")
if __name__ == "__main__":
main()

View File

@@ -15,6 +15,7 @@ from datetime import datetime
# Add the current directory to Python path # Add the current directory to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def test_imports(): def test_imports():
"""Test that all modules can be imported""" """Test that all modules can be imported"""
print("Testing imports...") print("Testing imports...")
@@ -27,46 +28,49 @@ def test_imports():
from usda_vision_system.storage.manager import StorageManager from usda_vision_system.storage.manager import StorageManager
from usda_vision_system.api.server import APIServer from usda_vision_system.api.server import APIServer
from usda_vision_system.main import USDAVisionSystem from usda_vision_system.main import USDAVisionSystem
print("✅ All imports successful") print("✅ All imports successful")
return True return True
except Exception as e: except Exception as e:
print(f"❌ Import failed: {e}") print(f"❌ Import failed: {e}")
return False return False
def test_configuration(): def test_configuration():
"""Test configuration loading""" """Test configuration loading"""
print("\nTesting configuration...") print("\nTesting configuration...")
try: try:
from usda_vision_system.core.config import Config from usda_vision_system.core.config import Config
# Test default config # Test default config
config = Config() config = Config()
print(f"✅ Default config loaded") print(f"✅ Default config loaded")
print(f" MQTT broker: {config.mqtt.broker_host}:{config.mqtt.broker_port}") print(f" MQTT broker: {config.mqtt.broker_host}:{config.mqtt.broker_port}")
print(f" Storage path: {config.storage.base_path}") print(f" Storage path: {config.storage.base_path}")
print(f" Cameras configured: {len(config.cameras)}") print(f" Cameras configured: {len(config.cameras)}")
# Test config file if it exists # Test config file if it exists
if os.path.exists("config.json"): if os.path.exists("config.json"):
config_file = Config("config.json") config_file = Config("config.json")
print(f"✅ Config file loaded") print(f"✅ Config file loaded")
return True return True
except Exception as e: except Exception as e:
print(f"❌ Configuration test failed: {e}") print(f"❌ Configuration test failed: {e}")
return False return False
def test_camera_discovery(): def test_camera_discovery():
"""Test camera discovery""" """Test camera discovery"""
print("\nTesting camera discovery...") print("\nTesting camera discovery...")
try: try:
sys.path.append('./python demo') sys.path.append("./python demo")
import mvsdk import mvsdk
devices = mvsdk.CameraEnumerateDevice() devices = mvsdk.CameraEnumerateDevice()
print(f"✅ Camera discovery successful") print(f"✅ Camera discovery successful")
print(f" Found {len(devices)} camera(s)") print(f" Found {len(devices)} camera(s)")
for i, device in enumerate(devices): for i, device in enumerate(devices):
try: try:
name = device.GetFriendlyName() name = device.GetFriendlyName()
@@ -74,13 +78,14 @@ def test_camera_discovery():
print(f" Camera {i}: {name} ({port_type})") print(f" Camera {i}: {name} ({port_type})")
except Exception as e: except Exception as e:
print(f" Camera {i}: Error getting info - {e}") print(f" Camera {i}: Error getting info - {e}")
return True return True
except Exception as e: except Exception as e:
print(f"❌ Camera discovery failed: {e}") print(f"❌ Camera discovery failed: {e}")
print(" Make sure GigE cameras are connected and python demo library is available") print(" Make sure GigE cameras are connected and python demo library is available")
return False return False
def test_storage_setup(): def test_storage_setup():
"""Test storage directory setup""" """Test storage directory setup"""
print("\nTesting storage setup...") print("\nTesting storage setup...")
@@ -88,22 +93,23 @@ def test_storage_setup():
from usda_vision_system.core.config import Config from usda_vision_system.core.config import Config
from usda_vision_system.storage.manager import StorageManager from usda_vision_system.storage.manager import StorageManager
from usda_vision_system.core.state_manager import StateManager from usda_vision_system.core.state_manager import StateManager
config = Config() config = Config()
state_manager = StateManager() state_manager = StateManager()
storage_manager = StorageManager(config, state_manager) storage_manager = StorageManager(config, state_manager)
# Test storage statistics # Test storage statistics
stats = storage_manager.get_storage_statistics() stats = storage_manager.get_storage_statistics()
print(f"✅ Storage manager initialized") print(f"✅ Storage manager initialized")
print(f" Base path: {stats.get('base_path', 'Unknown')}") print(f" Base path: {stats.get('base_path', 'Unknown')}")
print(f" Total files: {stats.get('total_files', 0)}") print(f" Total files: {stats.get('total_files', 0)}")
return True return True
except Exception as e: except Exception as e:
print(f"❌ Storage setup failed: {e}") print(f"❌ Storage setup failed: {e}")
return False return False
def test_mqtt_config(): def test_mqtt_config():
"""Test MQTT configuration (without connecting)""" """Test MQTT configuration (without connecting)"""
print("\nTesting MQTT configuration...") print("\nTesting MQTT configuration...")
@@ -112,45 +118,47 @@ def test_mqtt_config():
from usda_vision_system.mqtt.client import MQTTClient from usda_vision_system.mqtt.client import MQTTClient
from usda_vision_system.core.state_manager import StateManager from usda_vision_system.core.state_manager import StateManager
from usda_vision_system.core.events import EventSystem from usda_vision_system.core.events import EventSystem
config = Config() config = Config()
state_manager = StateManager() state_manager = StateManager()
event_system = EventSystem() event_system = EventSystem()
mqtt_client = MQTTClient(config, state_manager, event_system) mqtt_client = MQTTClient(config, state_manager, event_system)
status = mqtt_client.get_status() status = mqtt_client.get_status()
print(f"✅ MQTT client initialized") print(f"✅ MQTT client initialized")
print(f" Broker: {status['broker_host']}:{status['broker_port']}") print(f" Broker: {status['broker_host']}:{status['broker_port']}")
print(f" Topics: {len(status['subscribed_topics'])}") print(f" Topics: {len(status['subscribed_topics'])}")
for topic in status['subscribed_topics']: for topic in status["subscribed_topics"]:
print(f" - {topic}") print(f" - {topic}")
return True return True
except Exception as e: except Exception as e:
print(f"❌ MQTT configuration test failed: {e}") print(f"❌ MQTT configuration test failed: {e}")
return False return False
def test_system_initialization(): def test_system_initialization():
"""Test full system initialization (without starting)""" """Test full system initialization (without starting)"""
print("\nTesting system initialization...") print("\nTesting system initialization...")
try: try:
from usda_vision_system.main import USDAVisionSystem from usda_vision_system.main import USDAVisionSystem
# Create system instance # Create system instance
system = USDAVisionSystem() system = USDAVisionSystem()
# Check system status # Check system status
status = system.get_system_status() status = system.get_system_status()
print(f"✅ System initialized successfully") print(f"✅ System initialized successfully")
print(f" Running: {status['running']}") print(f" Running: {status['running']}")
print(f" Components initialized: {len(status['components'])}") print(f" Components initialized: {len(status['components'])}")
return True return True
except Exception as e: except Exception as e:
print(f"❌ System initialization failed: {e}") print(f"❌ System initialization failed: {e}")
return False return False
def test_api_endpoints(): def test_api_endpoints():
"""Test API endpoints if server is running""" """Test API endpoints if server is running"""
print("\nTesting API endpoints...") print("\nTesting API endpoints...")
@@ -159,7 +167,7 @@ def test_api_endpoints():
response = requests.get("http://localhost:8000/health", timeout=5) response = requests.get("http://localhost:8000/health", timeout=5)
if response.status_code == 200: if response.status_code == 200:
print("✅ API server is running") print("✅ API server is running")
# Test system status endpoint # Test system status endpoint
try: try:
response = requests.get("http://localhost:8000/system/status", timeout=5) response = requests.get("http://localhost:8000/system/status", timeout=5)
@@ -172,7 +180,7 @@ def test_api_endpoints():
print(f"⚠️ System status endpoint returned {response.status_code}") print(f"⚠️ System status endpoint returned {response.status_code}")
except Exception as e: except Exception as e:
print(f"⚠️ System status test failed: {e}") print(f"⚠️ System status test failed: {e}")
return True return True
else: else:
print(f"⚠️ API server returned status {response.status_code}") print(f"⚠️ API server returned status {response.status_code}")
@@ -184,34 +192,27 @@ def test_api_endpoints():
print(f"❌ API test failed: {e}") print(f"❌ API test failed: {e}")
return False return False
def main(): def main():
"""Run all tests""" """Run all tests"""
print("USDA Vision Camera System - Test Suite") print("USDA Vision Camera System - Test Suite")
print("=" * 50) print("=" * 50)
tests = [ tests = [test_imports, test_configuration, test_camera_discovery, test_storage_setup, test_mqtt_config, test_system_initialization, test_api_endpoints]
test_imports,
test_configuration,
test_camera_discovery,
test_storage_setup,
test_mqtt_config,
test_system_initialization,
test_api_endpoints
]
passed = 0 passed = 0
total = len(tests) total = len(tests)
for test in tests: for test in tests:
try: try:
if test(): if test():
passed += 1 passed += 1
except Exception as e: except Exception as e:
print(f"❌ Test {test.__name__} crashed: {e}") print(f"❌ Test {test.__name__} crashed: {e}")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(f"Test Results: {passed}/{total} tests passed") print(f"Test Results: {passed}/{total} tests passed")
if passed == total: if passed == total:
print("🎉 All tests passed! System appears to be working correctly.") print("🎉 All tests passed! System appears to be working correctly.")
return 0 return 0
@@ -219,5 +220,6 @@ def main():
print("⚠️ Some tests failed. Check the output above for details.") print("⚠️ Some tests failed. Check the output above for details.")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
class SystemStatusResponse(BaseModel): class SystemStatusResponse(BaseModel):
"""System status response model""" """System status response model"""
system_started: bool system_started: bool
mqtt_connected: bool mqtt_connected: bool
last_mqtt_message: Optional[str] = None last_mqtt_message: Optional[str] = None
@@ -23,6 +24,7 @@ class SystemStatusResponse(BaseModel):
class MachineStatusResponse(BaseModel): class MachineStatusResponse(BaseModel):
"""Machine status response model""" """Machine status response model"""
name: str name: str
state: str state: str
last_updated: str last_updated: str
@@ -30,8 +32,22 @@ class MachineStatusResponse(BaseModel):
mqtt_topic: Optional[str] = None mqtt_topic: Optional[str] = None
class MQTTStatusResponse(BaseModel):
"""MQTT status response model"""
connected: bool
broker_host: str
broker_port: int
subscribed_topics: List[str]
last_message_time: Optional[str] = None
message_count: int
error_count: int
uptime_seconds: Optional[float] = None
class CameraStatusResponse(BaseModel): class CameraStatusResponse(BaseModel):
"""Camera status response model""" """Camera status response model"""
name: str name: str
status: str status: str
is_recording: bool is_recording: bool
@@ -44,6 +60,7 @@ class CameraStatusResponse(BaseModel):
class RecordingInfoResponse(BaseModel): class RecordingInfoResponse(BaseModel):
"""Recording information response model""" """Recording information response model"""
camera_name: str camera_name: str
filename: str filename: str
start_time: str start_time: str
@@ -57,12 +74,16 @@ class RecordingInfoResponse(BaseModel):
class StartRecordingRequest(BaseModel): class StartRecordingRequest(BaseModel):
"""Start recording request model""" """Start recording request model"""
camera_name: str
filename: Optional[str] = None filename: Optional[str] = None
exposure_ms: Optional[float] = Field(default=None, description="Exposure time in milliseconds")
gain: Optional[float] = Field(default=None, description="Camera gain value")
fps: Optional[float] = Field(default=None, description="Target frames per second")
class StartRecordingResponse(BaseModel): class StartRecordingResponse(BaseModel):
"""Start recording response model""" """Start recording response model"""
success: bool success: bool
message: str message: str
filename: Optional[str] = None filename: Optional[str] = None
@@ -70,11 +91,15 @@ class StartRecordingResponse(BaseModel):
class StopRecordingRequest(BaseModel): class StopRecordingRequest(BaseModel):
"""Stop recording request model""" """Stop recording request model"""
camera_name: str
# Note: This model is currently unused as the stop recording endpoint
# only requires the camera_name from the URL path parameter
pass
class StopRecordingResponse(BaseModel): class StopRecordingResponse(BaseModel):
"""Stop recording response model""" """Stop recording response model"""
success: bool success: bool
message: str message: str
duration_seconds: Optional[float] = None duration_seconds: Optional[float] = None
@@ -82,6 +107,7 @@ class StopRecordingResponse(BaseModel):
class StorageStatsResponse(BaseModel): class StorageStatsResponse(BaseModel):
"""Storage statistics response model""" """Storage statistics response model"""
base_path: str base_path: str
total_files: int total_files: int
total_size_bytes: int total_size_bytes: int
@@ -91,6 +117,7 @@ class StorageStatsResponse(BaseModel):
class FileListRequest(BaseModel): class FileListRequest(BaseModel):
"""File list request model""" """File list request model"""
camera_name: Optional[str] = None camera_name: Optional[str] = None
start_date: Optional[str] = None start_date: Optional[str] = None
end_date: Optional[str] = None end_date: Optional[str] = None
@@ -99,17 +126,20 @@ class FileListRequest(BaseModel):
class FileListResponse(BaseModel): class FileListResponse(BaseModel):
"""File list response model""" """File list response model"""
files: List[Dict[str, Any]] files: List[Dict[str, Any]]
total_count: int total_count: int
class CleanupRequest(BaseModel): class CleanupRequest(BaseModel):
"""Cleanup request model""" """Cleanup request model"""
max_age_days: Optional[int] = None max_age_days: Optional[int] = None
class CleanupResponse(BaseModel): class CleanupResponse(BaseModel):
"""Cleanup response model""" """Cleanup response model"""
files_removed: int files_removed: int
bytes_freed: int bytes_freed: int
errors: List[str] errors: List[str]
@@ -117,6 +147,7 @@ class CleanupResponse(BaseModel):
class EventResponse(BaseModel): class EventResponse(BaseModel):
"""Event response model""" """Event response model"""
event_type: str event_type: str
source: str source: str
data: Dict[str, Any] data: Dict[str, Any]
@@ -125,6 +156,7 @@ class EventResponse(BaseModel):
class WebSocketMessage(BaseModel): class WebSocketMessage(BaseModel):
"""WebSocket message model""" """WebSocket message model"""
type: str type: str
data: Dict[str, Any] data: Dict[str, Any]
timestamp: Optional[str] = None timestamp: Optional[str] = None
@@ -132,13 +164,53 @@ class WebSocketMessage(BaseModel):
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
"""Error response model""" """Error response model"""
error: str error: str
details: Optional[str] = None details: Optional[str] = None
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat()) timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class CameraRecoveryResponse(BaseModel):
"""Camera recovery response model"""
success: bool
message: str
camera_name: str
operation: str
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class CameraTestResponse(BaseModel):
"""Camera connection test response model"""
success: bool
message: str
camera_name: str
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class MQTTEventResponse(BaseModel):
"""MQTT event response model"""
machine_name: str
topic: str
payload: str
normalized_state: str
timestamp: str
message_number: int
class MQTTEventsHistoryResponse(BaseModel):
"""MQTT events history response model"""
events: List[MQTTEventResponse]
total_events: int
last_updated: Optional[str] = None
class SuccessResponse(BaseModel): class SuccessResponse(BaseModel):
"""Success response model""" """Success response model"""
success: bool = True success: bool = True
message: str message: str
data: Optional[Dict[str, Any]] = None data: Optional[Dict[str, Any]] = None

View File

@@ -25,31 +25,31 @@ from .models import *
class WebSocketManager: class WebSocketManager:
"""Manages WebSocket connections for real-time updates""" """Manages WebSocket connections for real-time updates"""
def __init__(self): def __init__(self):
self.active_connections: List[WebSocket] = [] self.active_connections: List[WebSocket] = []
self.logger = logging.getLogger(f"{__name__}.WebSocketManager") self.logger = logging.getLogger(f"{__name__}.WebSocketManager")
async def connect(self, websocket: WebSocket): async def connect(self, websocket: WebSocket):
await websocket.accept() await websocket.accept()
self.active_connections.append(websocket) self.active_connections.append(websocket)
self.logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}") self.logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket): def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections: if websocket in self.active_connections:
self.active_connections.remove(websocket) self.active_connections.remove(websocket)
self.logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}") self.logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
async def send_personal_message(self, message: dict, websocket: WebSocket): async def send_personal_message(self, message: dict, websocket: WebSocket):
try: try:
await websocket.send_text(json.dumps(message)) await websocket.send_text(json.dumps(message))
except Exception as e: except Exception as e:
self.logger.error(f"Error sending personal message: {e}") self.logger.error(f"Error sending personal message: {e}")
async def broadcast(self, message: dict): async def broadcast(self, message: dict):
if not self.active_connections: if not self.active_connections:
return return
disconnected = [] disconnected = []
for connection in self.active_connections: for connection in self.active_connections:
try: try:
@@ -57,7 +57,7 @@ class WebSocketManager:
except Exception as e: except Exception as e:
self.logger.error(f"Error broadcasting to connection: {e}") self.logger.error(f"Error broadcasting to connection: {e}")
disconnected.append(connection) disconnected.append(connection)
# Remove disconnected connections # Remove disconnected connections
for connection in disconnected: for connection in disconnected:
self.disconnect(connection) self.disconnect(connection)
@@ -65,9 +65,8 @@ class WebSocketManager:
class APIServer: class APIServer:
"""FastAPI server for the USDA Vision Camera System""" """FastAPI server for the USDA Vision Camera System"""
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager, mqtt_client, storage_manager: StorageManager):
camera_manager, mqtt_client, storage_manager: StorageManager):
self.config = config self.config = config
self.state_manager = state_manager self.state_manager = state_manager
self.event_system = event_system self.event_system = event_system
@@ -75,111 +74,101 @@ class APIServer:
self.mqtt_client = mqtt_client self.mqtt_client = mqtt_client
self.storage_manager = storage_manager self.storage_manager = storage_manager
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# FastAPI app # FastAPI app
self.app = FastAPI( self.app = FastAPI(title="USDA Vision Camera System API", description="API for monitoring and controlling the USDA vision camera system", version="1.0.0")
title="USDA Vision Camera System API",
description="API for monitoring and controlling the USDA vision camera system",
version="1.0.0"
)
# WebSocket manager # WebSocket manager
self.websocket_manager = WebSocketManager() self.websocket_manager = WebSocketManager()
# Server state # Server state
self.server_start_time = datetime.now() self.server_start_time = datetime.now()
self.running = False self.running = False
self._server_thread: Optional[threading.Thread] = None self._server_thread: Optional[threading.Thread] = None
self._event_loop: Optional[asyncio.AbstractEventLoop] = None self._event_loop: Optional[asyncio.AbstractEventLoop] = None
# Setup CORS # Setup CORS
self.app.add_middleware( self.app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) # Configure appropriately for production
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Setup routes # Setup routes
self._setup_routes() self._setup_routes()
# Subscribe to events for WebSocket broadcasting # Subscribe to events for WebSocket broadcasting
self._setup_event_subscriptions() self._setup_event_subscriptions()
def _setup_routes(self): def _setup_routes(self):
"""Setup API routes""" """Setup API routes"""
@self.app.get("/", response_model=SuccessResponse) @self.app.get("/", response_model=SuccessResponse)
async def root(): async def root():
return SuccessResponse(message="USDA Vision Camera System API") return SuccessResponse(message="USDA Vision Camera System API")
@self.app.get("/health") @self.app.get("/health")
async def health_check(): async def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat()} return {"status": "healthy", "timestamp": datetime.now().isoformat()}
@self.app.get("/system/status", response_model=SystemStatusResponse) @self.app.get("/system/status", response_model=SystemStatusResponse)
async def get_system_status(): async def get_system_status():
"""Get overall system status""" """Get overall system status"""
try: try:
summary = self.state_manager.get_system_summary() summary = self.state_manager.get_system_summary()
uptime = (datetime.now() - self.server_start_time).total_seconds() uptime = (datetime.now() - self.server_start_time).total_seconds()
return SystemStatusResponse( 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)
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: except Exception as e:
self.logger.error(f"Error getting system status: {e}") self.logger.error(f"Error getting system status: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/machines", response_model=Dict[str, MachineStatusResponse]) @self.app.get("/machines", response_model=Dict[str, MachineStatusResponse])
async def get_machines(): async def get_machines():
"""Get all machine statuses""" """Get all machine statuses"""
try: try:
machines = self.state_manager.get_all_machines() machines = self.state_manager.get_all_machines()
return { 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()}
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: except Exception as e:
self.logger.error(f"Error getting machines: {e}") self.logger.error(f"Error getting machines: {e}")
raise HTTPException(status_code=500, detail=str(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]) @self.app.get("/cameras", response_model=Dict[str, CameraStatusResponse])
async def get_cameras(): async def get_cameras():
"""Get all camera statuses""" """Get all camera statuses"""
try: try:
cameras = self.state_manager.get_all_cameras() cameras = self.state_manager.get_all_cameras()
return { return {
name: CameraStatusResponse( 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)
name=camera.name,
status=camera.status.value,
is_recording=camera.is_recording,
last_checked=camera.last_checked.isoformat(),
last_error=camera.last_error,
device_info=camera.device_info,
current_recording_file=camera.current_recording_file,
recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None
)
for name, camera in cameras.items() for name, camera in cameras.items()
} }
except Exception as e: except Exception as e:
self.logger.error(f"Error getting cameras: {e}") self.logger.error(f"Error getting cameras: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse) @self.app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse)
async def get_camera_status(camera_name: str): async def get_camera_status(camera_name: str):
"""Get specific camera status""" """Get specific camera status"""
@@ -187,70 +176,158 @@ class APIServer:
camera = self.state_manager.get_camera_status(camera_name) camera = self.state_manager.get_camera_status(camera_name)
if not camera: if not camera:
raise HTTPException(status_code=404, detail=f"Camera not found: {camera_name}") raise HTTPException(status_code=404, detail=f"Camera not found: {camera_name}")
return CameraStatusResponse( 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)
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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
self.logger.error(f"Error getting camera status: {e}") self.logger.error(f"Error getting camera status: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse) @self.app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse)
async def start_recording(camera_name: str, request: StartRecordingRequest): async def start_recording(camera_name: str, request: StartRecordingRequest):
"""Manually start recording for a camera""" """Manually start recording for a camera"""
try: try:
if not self.camera_manager: if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available") raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_start_recording(camera_name, request.filename) 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: if success:
return StartRecordingResponse( # Get the actual filename that was used (with datetime prefix)
success=True, actual_filename = request.filename
message=f"Recording started for {camera_name}", if request.filename:
filename=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: else:
return StartRecordingResponse( return StartRecordingResponse(success=False, message=f"Failed to start recording for {camera_name}")
success=False,
message=f"Failed to start recording for {camera_name}"
)
except Exception as e: except Exception as e:
self.logger.error(f"Error starting recording: {e}") self.logger.error(f"Error starting recording: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse) @self.app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse)
async def stop_recording(camera_name: str): async def stop_recording(camera_name: str):
"""Manually stop recording for a camera""" """Manually stop recording for a camera"""
try: try:
if not self.camera_manager: if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available") raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_stop_recording(camera_name) success = self.camera_manager.manual_stop_recording(camera_name)
if success: if success:
return StopRecordingResponse( return StopRecordingResponse(success=True, message=f"Recording stopped for {camera_name}")
success=True,
message=f"Recording stopped for {camera_name}"
)
else: else:
return StopRecordingResponse( return StopRecordingResponse(success=False, message=f"Failed to stop recording for {camera_name}")
success=False,
message=f"Failed to stop recording for {camera_name}"
)
except Exception as e: except Exception as e:
self.logger.error(f"Error stopping recording: {e}") self.logger.error(f"Error stopping recording: {e}")
raise HTTPException(status_code=500, detail=str(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.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.get("/recordings", response_model=Dict[str, RecordingInfoResponse]) @self.app.get("/recordings", response_model=Dict[str, RecordingInfoResponse])
async def get_recordings(): async def get_recordings():
"""Get all recording sessions""" """Get all recording sessions"""
@@ -266,14 +343,14 @@ class APIServer:
file_size_bytes=recording.file_size_bytes, file_size_bytes=recording.file_size_bytes,
frame_count=recording.frame_count, frame_count=recording.frame_count,
duration_seconds=(recording.end_time - recording.start_time).total_seconds() if recording.end_time else None, duration_seconds=(recording.end_time - recording.start_time).total_seconds() if recording.end_time else None,
error_message=recording.error_message error_message=recording.error_message,
) )
for rid, recording in recordings.items() for rid, recording in recordings.items()
} }
except Exception as e: except Exception as e:
self.logger.error(f"Error getting recordings: {e}") self.logger.error(f"Error getting recordings: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/storage/stats", response_model=StorageStatsResponse) @self.app.get("/storage/stats", response_model=StorageStatsResponse)
async def get_storage_stats(): async def get_storage_stats():
"""Get storage statistics""" """Get storage statistics"""
@@ -283,34 +360,26 @@ class APIServer:
except Exception as e: except Exception as e:
self.logger.error(f"Error getting storage stats: {e}") self.logger.error(f"Error getting storage stats: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/files", response_model=FileListResponse) @self.app.post("/storage/files", response_model=FileListResponse)
async def get_files(request: FileListRequest): async def get_files(request: FileListRequest):
"""Get list of recording files""" """Get list of recording files"""
try: try:
start_date = None start_date = None
end_date = None end_date = None
if request.start_date: if request.start_date:
start_date = datetime.fromisoformat(request.start_date) start_date = datetime.fromisoformat(request.start_date)
if request.end_date: if request.end_date:
end_date = datetime.fromisoformat(request.end_date) end_date = datetime.fromisoformat(request.end_date)
files = self.storage_manager.get_recording_files( files = self.storage_manager.get_recording_files(camera_name=request.camera_name, start_date=start_date, end_date=end_date, limit=request.limit)
camera_name=request.camera_name,
start_date=start_date, return FileListResponse(files=files, total_count=len(files))
end_date=end_date,
limit=request.limit
)
return FileListResponse(
files=files,
total_count=len(files)
)
except Exception as e: except Exception as e:
self.logger.error(f"Error getting files: {e}") self.logger.error(f"Error getting files: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/cleanup", response_model=CleanupResponse) @self.app.post("/storage/cleanup", response_model=CleanupResponse)
async def cleanup_storage(request: CleanupRequest): async def cleanup_storage(request: CleanupRequest):
"""Clean up old storage files""" """Clean up old storage files"""
@@ -320,7 +389,7 @@ class APIServer:
except Exception as e: except Exception as e:
self.logger.error(f"Error during cleanup: {e}") self.logger.error(f"Error during cleanup: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@self.app.websocket("/ws") @self.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time updates""" """WebSocket endpoint for real-time updates"""
@@ -330,9 +399,7 @@ class APIServer:
# Keep connection alive and handle incoming messages # Keep connection alive and handle incoming messages
data = await websocket.receive_text() data = await websocket.receive_text()
# Echo back for now - could implement commands later # Echo back for now - could implement commands later
await self.websocket_manager.send_personal_message( await self.websocket_manager.send_personal_message({"type": "echo", "data": data}, websocket)
{"type": "echo", "data": data}, websocket
)
except WebSocketDisconnect: except WebSocketDisconnect:
self.websocket_manager.disconnect(websocket) self.websocket_manager.disconnect(websocket)
@@ -342,21 +409,12 @@ class APIServer:
def broadcast_event(event: Event): def broadcast_event(event: Event):
"""Broadcast event to all WebSocket connections""" """Broadcast event to all WebSocket connections"""
try: try:
message = { message = {"type": "event", "event_type": event.event_type.value, "source": event.source, "data": event.data, "timestamp": event.timestamp.isoformat()}
"type": "event",
"event_type": event.event_type.value,
"source": event.source,
"data": event.data,
"timestamp": event.timestamp.isoformat()
}
# Schedule the broadcast in the event loop thread-safely # Schedule the broadcast in the event loop thread-safely
if self._event_loop and not self._event_loop.is_closed(): if self._event_loop and not self._event_loop.is_closed():
# Use call_soon_threadsafe to schedule the coroutine from another thread # Use call_soon_threadsafe to schedule the coroutine from another thread
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(self.websocket_manager.broadcast(message), self._event_loop)
self.websocket_manager.broadcast(message),
self._event_loop
)
else: else:
self.logger.debug("Event loop not available for broadcasting") self.logger.debug("Event loop not available for broadcasting")
@@ -411,12 +469,7 @@ class APIServer:
self._event_loop = asyncio.new_event_loop() self._event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._event_loop) asyncio.set_event_loop(self._event_loop)
uvicorn.run( uvicorn.run(self.app, host=self.config.system.api_host, port=self.config.system.api_port, log_level="info")
self.app,
host=self.config.system.api_host,
port=self.config.system.api_port,
log_level="info"
)
except Exception as e: except Exception as e:
self.logger.error(f"Error running API server: {e}") self.logger.error(f"Error running API server: {e}")
finally: finally:
@@ -429,11 +482,4 @@ class APIServer:
def get_server_info(self) -> Dict[str, Any]: def get_server_info(self) -> Dict[str, Any]:
"""Get server information""" """Get server information"""
return { return {"running": self.running, "host": self.config.system.api_host, "port": self.config.system.api_port, "start_time": self.server_start_time.isoformat(), "uptime_seconds": (datetime.now() - self.server_start_time).total_seconds(), "websocket_connections": len(self.websocket_manager.active_connections)}
"running": self.running,
"host": self.config.system.api_host,
"port": self.config.system.api_port,
"start_time": self.server_start_time.isoformat(),
"uptime_seconds": (datetime.now() - self.server_start_time).total_seconds(),
"websocket_connections": len(self.websocket_manager.active_connections)
}

View File

@@ -13,7 +13,7 @@ from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime from datetime import datetime
# Add python demo to path # Add python demo to path
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'python demo')) sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "python demo"))
import mvsdk import mvsdk
from ..core.config import Config, CameraConfig from ..core.config import Config, CameraConfig
@@ -22,271 +22,233 @@ from ..core.events import EventSystem, EventType, Event, publish_camera_status_c
from ..core.timezone_utils import format_filename_timestamp from ..core.timezone_utils import format_filename_timestamp
from .recorder import CameraRecorder from .recorder import CameraRecorder
from .monitor import CameraMonitor from .monitor import CameraMonitor
from .sdk_config import initialize_sdk_with_suppression
class CameraManager: class CameraManager:
"""Manages all cameras in the system""" """Manages all cameras in the system"""
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem): def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem):
self.config = config self.config = config
self.state_manager = state_manager self.state_manager = state_manager
self.event_system = event_system self.event_system = event_system
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Initialize SDK early to suppress error messages
initialize_sdk_with_suppression()
# Camera management # Camera management
self.available_cameras: List[Any] = [] # mvsdk camera device info self.available_cameras: List[Any] = [] # mvsdk camera device info
self.camera_recorders: Dict[str, CameraRecorder] = {} # camera_name -> recorder self.camera_recorders: Dict[str, CameraRecorder] = {} # camera_name -> recorder
self.camera_monitor: Optional[CameraMonitor] = None self.camera_monitor: Optional[CameraMonitor] = None
# Threading # Threading
self._lock = threading.RLock() self._lock = threading.RLock()
self.running = False self.running = False
# Subscribe to machine state changes # Subscribe to machine state changes
self.event_system.subscribe(EventType.MACHINE_STATE_CHANGED, self._on_machine_state_changed) self.event_system.subscribe(EventType.MACHINE_STATE_CHANGED, self._on_machine_state_changed)
# Initialize camera discovery # Initialize camera discovery
self._discover_cameras() self._discover_cameras()
# Create camera monitor # Create camera monitor
self.camera_monitor = CameraMonitor( self.camera_monitor = CameraMonitor(config=config, state_manager=state_manager, event_system=event_system, camera_manager=self)
config=config,
state_manager=state_manager,
event_system=event_system,
camera_manager=self
)
def start(self) -> bool: def start(self) -> bool:
"""Start the camera manager""" """Start the camera manager"""
if self.running: if self.running:
self.logger.warning("Camera manager is already running") self.logger.warning("Camera manager is already running")
return True return True
self.logger.info("Starting camera manager...") self.logger.info("Starting camera manager...")
self.running = True self.running = True
# Start camera monitor # Start camera monitor
if self.camera_monitor: if self.camera_monitor:
self.camera_monitor.start() self.camera_monitor.start()
# Initialize camera recorders # Initialize camera recorders
self._initialize_recorders() self._initialize_recorders()
self.logger.info("Camera manager started successfully") self.logger.info("Camera manager started successfully")
return True return True
def stop(self) -> None: def stop(self) -> None:
"""Stop the camera manager""" """Stop the camera manager"""
if not self.running: if not self.running:
return return
self.logger.info("Stopping camera manager...") self.logger.info("Stopping camera manager...")
self.running = False self.running = False
# Stop camera monitor # Stop camera monitor
if self.camera_monitor: if self.camera_monitor:
self.camera_monitor.stop() self.camera_monitor.stop()
# Stop all active recordings # Stop all active recordings
with self._lock: with self._lock:
for recorder in self.camera_recorders.values(): for recorder in self.camera_recorders.values():
if recorder.is_recording(): if recorder.is_recording():
recorder.stop_recording() recorder.stop_recording()
recorder.cleanup() recorder.cleanup()
self.logger.info("Camera manager stopped") self.logger.info("Camera manager stopped")
def _discover_cameras(self) -> None: def _discover_cameras(self) -> None:
"""Discover available GigE cameras""" """Discover available GigE cameras"""
try: try:
self.logger.info("Discovering GigE cameras...") self.logger.info("Discovering GigE cameras...")
# Enumerate cameras using mvsdk # Enumerate cameras using mvsdk
device_list = mvsdk.CameraEnumerateDevice() device_list = mvsdk.CameraEnumerateDevice()
self.available_cameras = device_list self.available_cameras = device_list
self.logger.info(f"Found {len(device_list)} camera(s)") self.logger.info(f"Found {len(device_list)} camera(s)")
for i, dev_info in enumerate(device_list): for i, dev_info in enumerate(device_list):
try: try:
name = dev_info.GetFriendlyName() name = dev_info.GetFriendlyName()
port_type = dev_info.GetPortType() port_type = dev_info.GetPortType()
serial = getattr(dev_info, 'acSn', 'Unknown') serial = getattr(dev_info, "acSn", "Unknown")
self.logger.info(f" Camera {i}: {name} ({port_type}) - Serial: {serial}") self.logger.info(f" Camera {i}: {name} ({port_type}) - Serial: {serial}")
# Update state manager with discovered camera # Update state manager with discovered camera
camera_name = f"camera{i+1}" # Default naming camera_name = f"camera{i+1}" # Default naming
self.state_manager.update_camera_status( self.state_manager.update_camera_status(name=camera_name, status="available", device_info={"friendly_name": name, "port_type": port_type, "serial_number": serial, "device_index": i})
name=camera_name,
status="available",
device_info={
"friendly_name": name,
"port_type": port_type,
"serial_number": serial,
"device_index": i
}
)
except Exception as e: except Exception as e:
self.logger.error(f"Error processing camera {i}: {e}") self.logger.error(f"Error processing camera {i}: {e}")
except Exception as e: except Exception as e:
self.logger.error(f"Error discovering cameras: {e}") self.logger.error(f"Error discovering cameras: {e}")
self.available_cameras = [] self.available_cameras = []
def _initialize_recorders(self) -> None: def _initialize_recorders(self) -> None:
"""Initialize camera recorders for configured cameras""" """Initialize camera recorders for configured cameras"""
with self._lock: with self._lock:
for camera_config in self.config.cameras: for camera_config in self.config.cameras:
if not camera_config.enabled: if not camera_config.enabled:
continue continue
try: try:
# Find matching physical camera # Find matching physical camera
device_info = self._find_camera_device(camera_config.name) device_info = self._find_camera_device(camera_config.name)
if device_info is None: if device_info is None:
self.logger.warning(f"No physical camera found for configured camera: {camera_config.name}") self.logger.warning(f"No physical camera found for configured camera: {camera_config.name}")
# Update state to indicate camera is not available # Update state to indicate camera is not available
self.state_manager.update_camera_status( self.state_manager.update_camera_status(name=camera_config.name, status="not_found", device_info=None)
name=camera_config.name,
status="not_found",
device_info=None
)
continue continue
# Create recorder (this will attempt to initialize the camera) # Create recorder (uses lazy initialization - camera will be initialized when recording starts)
recorder = CameraRecorder( recorder = CameraRecorder(camera_config=camera_config, device_info=device_info, state_manager=self.state_manager, event_system=self.event_system)
camera_config=camera_config,
device_info=device_info,
state_manager=self.state_manager,
event_system=self.event_system
)
# Check if camera initialization was successful
if recorder.hCamera is None:
self.logger.warning(f"Camera {camera_config.name} failed to initialize, skipping")
# Update state to indicate camera initialization failed
self.state_manager.update_camera_status(
name=camera_config.name,
status="initialization_failed",
device_info={"error": "Camera initialization failed"}
)
continue
# Add recorder to the list (camera will be initialized lazily when needed)
self.camera_recorders[camera_config.name] = recorder self.camera_recorders[camera_config.name] = recorder
self.logger.info(f"Successfully initialized recorder for camera: {camera_config.name}") self.logger.info(f"Successfully created recorder for camera: {camera_config.name} (lazy initialization)")
except Exception as e: except Exception as e:
self.logger.error(f"Error initializing recorder for {camera_config.name}: {e}") self.logger.error(f"Error initializing recorder for {camera_config.name}: {e}")
# Update state to indicate error # Update state to indicate error
self.state_manager.update_camera_status( self.state_manager.update_camera_status(name=camera_config.name, status="error", device_info={"error": str(e)})
name=camera_config.name,
status="error",
device_info={"error": str(e)}
)
def _find_camera_device(self, camera_name: str) -> Optional[Any]: def _find_camera_device(self, camera_name: str) -> Optional[Any]:
"""Find physical camera device for a configured camera""" """Find physical camera device for a configured camera"""
# For now, use simple mapping: camera1 -> device 0, camera2 -> device 1, etc. # For now, use simple mapping: camera1 -> device 0, camera2 -> device 1, etc.
# This could be enhanced to use serial numbers or other identifiers # This could be enhanced to use serial numbers or other identifiers
camera_index_map = { camera_index_map = {"camera1": 0, "camera2": 1, "camera3": 2, "camera4": 3}
"camera1": 0,
"camera2": 1,
"camera3": 2,
"camera4": 3
}
device_index = camera_index_map.get(camera_name) device_index = camera_index_map.get(camera_name)
if device_index is not None and device_index < len(self.available_cameras): if device_index is not None and device_index < len(self.available_cameras):
return self.available_cameras[device_index] return self.available_cameras[device_index]
return None return None
def _on_machine_state_changed(self, event: Event) -> None: def _on_machine_state_changed(self, event: Event) -> None:
"""Handle machine state change events""" """Handle machine state change events"""
try: try:
machine_name = event.data.get("machine_name") machine_name = event.data.get("machine_name")
new_state = event.data.get("state") new_state = event.data.get("state")
if not machine_name or not new_state: if not machine_name or not new_state:
return return
self.logger.info(f"Handling machine state change: {machine_name} -> {new_state}") self.logger.info(f"Handling machine state change: {machine_name} -> {new_state}")
# Find camera associated with this machine # Find camera associated with this machine
camera_config = None camera_config = None
for config in self.config.cameras: for config in self.config.cameras:
if config.machine_topic == machine_name: if config.machine_topic == machine_name:
camera_config = config camera_config = config
break break
if not camera_config: if not camera_config:
self.logger.warning(f"No camera configured for machine: {machine_name}") self.logger.warning(f"No camera configured for machine: {machine_name}")
return return
# Get the recorder for this camera # Get the recorder for this camera
recorder = self.camera_recorders.get(camera_config.name) recorder = self.camera_recorders.get(camera_config.name)
if not recorder: if not recorder:
self.logger.warning(f"No recorder found for camera: {camera_config.name}") self.logger.warning(f"No recorder found for camera: {camera_config.name}")
return return
# Handle state change # Handle state change
if new_state == "on": if new_state == "on":
self._start_recording(camera_config.name, recorder) self._start_recording(camera_config.name, recorder)
elif new_state in ["off", "error"]: elif new_state in ["off", "error"]:
self._stop_recording(camera_config.name, recorder) self._stop_recording(camera_config.name, recorder)
except Exception as e: except Exception as e:
self.logger.error(f"Error handling machine state change: {e}") self.logger.error(f"Error handling machine state change: {e}")
def _start_recording(self, camera_name: str, recorder: CameraRecorder) -> None: def _start_recording(self, camera_name: str, recorder: CameraRecorder) -> None:
"""Start recording for a camera""" """Start recording for a camera"""
try: try:
if recorder.is_recording(): if recorder.is_recording():
self.logger.info(f"Camera {camera_name} is already recording") self.logger.info(f"Camera {camera_name} is already recording")
return return
# Generate filename with Atlanta timezone timestamp # Generate filename with Atlanta timezone timestamp
timestamp = format_filename_timestamp() timestamp = format_filename_timestamp()
filename = f"{camera_name}_recording_{timestamp}.avi" filename = f"{camera_name}_recording_{timestamp}.avi"
# Start recording # Start recording
success = recorder.start_recording(filename) success = recorder.start_recording(filename)
if success: if success:
self.logger.info(f"Started recording for camera {camera_name}: {filename}") self.logger.info(f"Started recording for camera {camera_name}: {filename}")
else: else:
self.logger.error(f"Failed to start recording for camera {camera_name}") self.logger.error(f"Failed to start recording for camera {camera_name}")
except Exception as e: except Exception as e:
self.logger.error(f"Error starting recording for {camera_name}: {e}") self.logger.error(f"Error starting recording for {camera_name}: {e}")
def _stop_recording(self, camera_name: str, recorder: CameraRecorder) -> None: def _stop_recording(self, camera_name: str, recorder: CameraRecorder) -> None:
"""Stop recording for a camera""" """Stop recording for a camera"""
try: try:
if not recorder.is_recording(): if not recorder.is_recording():
self.logger.info(f"Camera {camera_name} is not recording") self.logger.info(f"Camera {camera_name} is not recording")
return return
# Stop recording # Stop recording
success = recorder.stop_recording() success = recorder.stop_recording()
if success: if success:
self.logger.info(f"Stopped recording for camera {camera_name}") self.logger.info(f"Stopped recording for camera {camera_name}")
else: else:
self.logger.error(f"Failed to stop recording for camera {camera_name}") self.logger.error(f"Failed to stop recording for camera {camera_name}")
except Exception as e: except Exception as e:
self.logger.error(f"Error stopping recording for {camera_name}: {e}") self.logger.error(f"Error stopping recording for {camera_name}: {e}")
def get_camera_status(self, camera_name: str) -> Optional[Dict[str, Any]]: def get_camera_status(self, camera_name: str) -> Optional[Dict[str, Any]]:
"""Get status of a specific camera""" """Get status of a specific camera"""
recorder = self.camera_recorders.get(camera_name) recorder = self.camera_recorders.get(camera_name)
if not recorder: if not recorder:
return None return None
return recorder.get_status() return recorder.get_status()
def get_all_camera_status(self) -> Dict[str, Dict[str, Any]]: def get_all_camera_status(self) -> Dict[str, Dict[str, Any]]:
"""Get status of all cameras""" """Get status of all cameras"""
status = {} status = {}
@@ -294,50 +256,174 @@ class CameraManager:
for camera_name, recorder in self.camera_recorders.items(): for camera_name, recorder in self.camera_recorders.items():
status[camera_name] = recorder.get_status() status[camera_name] = recorder.get_status()
return status return status
def manual_start_recording(self, camera_name: str, filename: Optional[str] = None) -> bool: def manual_start_recording(self, camera_name: str, filename: Optional[str] = None, exposure_ms: Optional[float] = None, gain: Optional[float] = None, fps: Optional[float] = None) -> bool:
"""Manually start recording for a camera""" """Manually start recording for a camera with optional camera settings"""
recorder = self.camera_recorders.get(camera_name) recorder = self.camera_recorders.get(camera_name)
if not recorder: if not recorder:
self.logger.error(f"Camera not found: {camera_name}") self.logger.error(f"Camera not found: {camera_name}")
return False return False
if not filename: # Update camera settings if provided
timestamp = format_filename_timestamp() if exposure_ms is not None or gain is not None or fps is not None:
settings_updated = recorder.update_camera_settings(exposure_ms=exposure_ms, gain=gain, target_fps=fps)
if not settings_updated:
self.logger.warning(f"Failed to update camera settings for {camera_name}")
# Generate filename with datetime prefix
timestamp = format_filename_timestamp()
if filename:
# Always prepend datetime to the provided filename
filename = f"{timestamp}_{filename}"
else:
filename = f"{camera_name}_manual_{timestamp}.avi" filename = f"{camera_name}_manual_{timestamp}.avi"
return recorder.start_recording(filename) return recorder.start_recording(filename)
def manual_stop_recording(self, camera_name: str) -> bool: def manual_stop_recording(self, camera_name: str) -> bool:
"""Manually stop recording for a camera""" """Manually stop recording for a camera"""
recorder = self.camera_recorders.get(camera_name) recorder = self.camera_recorders.get(camera_name)
if not recorder: if not recorder:
self.logger.error(f"Camera not found: {camera_name}") self.logger.error(f"Camera not found: {camera_name}")
return False return False
return recorder.stop_recording() return recorder.stop_recording()
def get_available_cameras(self) -> List[Dict[str, Any]]: def get_available_cameras(self) -> List[Dict[str, Any]]:
"""Get list of available physical cameras""" """Get list of available physical cameras"""
cameras = [] cameras = []
for i, dev_info in enumerate(self.available_cameras): for i, dev_info in enumerate(self.available_cameras):
try: try:
cameras.append({ cameras.append({"index": i, "name": dev_info.GetFriendlyName(), "port_type": dev_info.GetPortType(), "serial_number": getattr(dev_info, "acSn", "Unknown")})
"index": i,
"name": dev_info.GetFriendlyName(),
"port_type": dev_info.GetPortType(),
"serial_number": getattr(dev_info, 'acSn', 'Unknown')
})
except Exception as e: except Exception as e:
self.logger.error(f"Error getting info for camera {i}: {e}") self.logger.error(f"Error getting info for camera {i}: {e}")
return cameras return cameras
def refresh_camera_discovery(self) -> int: def refresh_camera_discovery(self) -> int:
"""Refresh camera discovery and return number of cameras found""" """Refresh camera discovery and return number of cameras found"""
self._discover_cameras() self._discover_cameras()
return len(self.available_cameras) return len(self.available_cameras)
def is_running(self) -> bool: def is_running(self) -> bool:
"""Check if camera manager is running""" """Check if camera manager is running"""
return self.running return self.running
def test_camera_connection(self, camera_name: str) -> bool:
"""Test connection for a specific camera"""
recorder = self.camera_recorders.get(camera_name)
if not recorder:
self.logger.error(f"Camera not found: {camera_name}")
return False
return recorder.test_connection()
def reconnect_camera(self, camera_name: str) -> bool:
"""Attempt to reconnect a specific camera"""
recorder = self.camera_recorders.get(camera_name)
if not recorder:
self.logger.error(f"Camera not found: {camera_name}")
return False
success = recorder.reconnect()
# Update camera status based on result
if success:
self.state_manager.update_camera_status(name=camera_name, status="connected", error=None)
else:
self.state_manager.update_camera_status(name=camera_name, status="connection_failed", error="Reconnection failed")
return success
def restart_camera_grab(self, camera_name: str) -> bool:
"""Restart grab process for a specific camera"""
recorder = self.camera_recorders.get(camera_name)
if not recorder:
self.logger.error(f"Camera not found: {camera_name}")
return False
success = recorder.restart_grab()
# Update camera status based on result
if success:
self.state_manager.update_camera_status(name=camera_name, status="connected", error=None)
else:
self.state_manager.update_camera_status(name=camera_name, status="grab_failed", error="Grab restart failed")
return success
def reset_camera_timestamp(self, camera_name: str) -> bool:
"""Reset timestamp for a specific camera"""
recorder = self.camera_recorders.get(camera_name)
if not recorder:
self.logger.error(f"Camera not found: {camera_name}")
return False
return recorder.reset_timestamp()
def full_reset_camera(self, camera_name: str) -> bool:
"""Perform full reset for a specific camera"""
recorder = self.camera_recorders.get(camera_name)
if not recorder:
self.logger.error(f"Camera not found: {camera_name}")
return False
success = recorder.full_reset()
# Update camera status based on result
if success:
self.state_manager.update_camera_status(name=camera_name, status="connected", error=None)
else:
self.state_manager.update_camera_status(name=camera_name, status="reset_failed", error="Full reset failed")
return success
def reinitialize_failed_camera(self, camera_name: str) -> bool:
"""Attempt to reinitialize a camera that failed to initialize"""
with self._lock:
# Find the camera config
camera_config = None
for config in self.config.cameras:
if config.name == camera_name:
camera_config = config
break
if not camera_config:
self.logger.error(f"No configuration found for camera: {camera_name}")
return False
if not camera_config.enabled:
self.logger.error(f"Camera {camera_name} is disabled in configuration")
return False
try:
# Remove existing recorder if any
if camera_name in self.camera_recorders:
old_recorder = self.camera_recorders[camera_name]
try:
old_recorder._cleanup_camera()
except:
pass # Ignore cleanup errors
del self.camera_recorders[camera_name]
# Find matching physical camera
device_info = self._find_camera_device(camera_name)
if device_info is None:
self.logger.warning(f"No physical camera found for configured camera: {camera_name}")
self.state_manager.update_camera_status(name=camera_name, status="not_found", device_info=None)
return False
# Create new recorder (uses lazy initialization)
recorder = CameraRecorder(camera_config=camera_config, device_info=device_info, state_manager=self.state_manager, event_system=self.event_system)
# Success - add to recorders (camera will be initialized lazily when needed)
self.camera_recorders[camera_name] = recorder
self.state_manager.update_camera_status(name=camera_name, status="connected", error=None)
self.logger.info(f"Successfully reinitialized camera recorder: {camera_name} (lazy initialization)")
return True
except Exception as e:
self.logger.error(f"Error reinitializing camera {camera_name}: {e}")
self.state_manager.update_camera_status(name=camera_name, status="error", device_info={"error": str(e)})
return False

View File

@@ -9,240 +9,236 @@ import os
import threading import threading
import time import time
import logging import logging
import contextlib
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
# Add python demo to path # Add python demo to path
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'python demo')) sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "python demo"))
import mvsdk import mvsdk
from ..core.config import Config from ..core.config import Config
from ..core.state_manager import StateManager, CameraStatus from ..core.state_manager import StateManager, CameraStatus
from ..core.events import EventSystem, publish_camera_status_changed from ..core.events import EventSystem, publish_camera_status_changed
from .sdk_config import ensure_sdk_initialized
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
# Save original file descriptors
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
# Redirect stderr and stdout to devnull
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
os.close(devnull)
yield
finally:
# Restore original file descriptors
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)
class CameraMonitor: class CameraMonitor:
"""Monitors camera status and availability""" """Monitors camera status and availability"""
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager=None): def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager=None):
self.config = config self.config = config
self.state_manager = state_manager self.state_manager = state_manager
self.event_system = event_system self.event_system = event_system
self.camera_manager = camera_manager # Reference to camera manager self.camera_manager = camera_manager # Reference to camera manager
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Monitoring settings # Monitoring settings
self.check_interval = config.system.camera_check_interval_seconds self.check_interval = config.system.camera_check_interval_seconds
# Threading # Threading
self.running = False self.running = False
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
# Status tracking # Status tracking
self.last_check_time: Optional[float] = None self.last_check_time: Optional[float] = None
self.check_count = 0 self.check_count = 0
self.error_count = 0 self.error_count = 0
def start(self) -> bool: def start(self) -> bool:
"""Start camera monitoring""" """Start camera monitoring"""
if self.running: if self.running:
self.logger.warning("Camera monitor is already running") self.logger.warning("Camera monitor is already running")
return True return True
self.logger.info(f"Starting camera monitor (check interval: {self.check_interval}s)") self.logger.info(f"Starting camera monitor (check interval: {self.check_interval}s)")
self.running = True self.running = True
self._stop_event.clear() self._stop_event.clear()
# Start monitoring thread # Start monitoring thread
self._thread = threading.Thread(target=self._monitoring_loop, daemon=True) self._thread = threading.Thread(target=self._monitoring_loop, daemon=True)
self._thread.start() self._thread.start()
return True return True
def stop(self) -> None: def stop(self) -> None:
"""Stop camera monitoring""" """Stop camera monitoring"""
if not self.running: if not self.running:
return return
self.logger.info("Stopping camera monitor...") self.logger.info("Stopping camera monitor...")
self.running = False self.running = False
self._stop_event.set() self._stop_event.set()
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
self._thread.join(timeout=5) self._thread.join(timeout=5)
self.logger.info("Camera monitor stopped") self.logger.info("Camera monitor stopped")
def _monitoring_loop(self) -> None: def _monitoring_loop(self) -> None:
"""Main monitoring loop""" """Main monitoring loop"""
self.logger.info("Camera monitoring loop started") self.logger.info("Camera monitoring loop started")
while self.running and not self._stop_event.is_set(): while self.running and not self._stop_event.is_set():
try: try:
self.last_check_time = time.time() self.last_check_time = time.time()
self.check_count += 1 self.check_count += 1
# Check all configured cameras # Check all configured cameras
self._check_all_cameras() self._check_all_cameras()
# Wait for next check # Wait for next check
if self._stop_event.wait(self.check_interval): if self._stop_event.wait(self.check_interval):
break break
except Exception as e: except Exception as e:
self.error_count += 1 self.error_count += 1
self.logger.error(f"Error in camera monitoring loop: {e}") self.logger.error(f"Error in camera monitoring loop: {e}")
# Wait a bit before retrying # Wait a bit before retrying
if self._stop_event.wait(min(self.check_interval, 10)): if self._stop_event.wait(min(self.check_interval, 10)):
break break
self.logger.info("Camera monitoring loop ended") self.logger.info("Camera monitoring loop ended")
def _check_all_cameras(self) -> None: def _check_all_cameras(self) -> None:
"""Check status of all configured cameras""" """Check status of all configured cameras"""
for camera_config in self.config.cameras: for camera_config in self.config.cameras:
if not camera_config.enabled: if not camera_config.enabled:
continue continue
try: try:
self._check_camera_status(camera_config.name) self._check_camera_status(camera_config.name)
except Exception as e: except Exception as e:
self.logger.error(f"Error checking camera {camera_config.name}: {e}") self.logger.error(f"Error checking camera {camera_config.name}: {e}")
def _check_camera_status(self, camera_name: str) -> None: def _check_camera_status(self, camera_name: str) -> None:
"""Check status of a specific camera""" """Check status of a specific camera"""
try: try:
# Get current status from state manager # Get current status from state manager
current_info = self.state_manager.get_camera_status(camera_name) current_info = self.state_manager.get_camera_status(camera_name)
# Perform actual camera check # Perform actual camera check
status, details, device_info = self._perform_camera_check(camera_name) status, details, device_info = self._perform_camera_check(camera_name)
# Update state if changed # Update state if changed
old_status = current_info.status.value if current_info else "unknown" old_status = current_info.status.value if current_info else "unknown"
if old_status != status: if old_status != status:
self.state_manager.update_camera_status( self.state_manager.update_camera_status(name=camera_name, status=status, error=details if status == "error" else None, device_info=device_info)
name=camera_name,
status=status,
error=details if status == "error" else None,
device_info=device_info
)
# Publish status change event # Publish status change event
publish_camera_status_changed( publish_camera_status_changed(camera_name=camera_name, status=status, details=details)
camera_name=camera_name,
status=status,
details=details
)
self.logger.info(f"Camera {camera_name} status changed: {old_status} -> {status}") self.logger.info(f"Camera {camera_name} status changed: {old_status} -> {status}")
except Exception as e: except Exception as e:
self.logger.error(f"Error checking camera {camera_name}: {e}") self.logger.error(f"Error checking camera {camera_name}: {e}")
# Update to error state # Update to error state
self.state_manager.update_camera_status( self.state_manager.update_camera_status(name=camera_name, status="error", error=str(e))
name=camera_name,
status="error",
error=str(e)
)
def _perform_camera_check(self, camera_name: str) -> tuple[str, str, Optional[Dict[str, Any]]]: def _perform_camera_check(self, camera_name: str) -> tuple[str, str, Optional[Dict[str, Any]]]:
"""Perform actual camera availability check""" """Perform actual camera availability check"""
try: try:
# Get camera device info from camera manager # Get camera device info from camera manager
if not self.camera_manager: if not self.camera_manager:
return "error", "Camera manager not available", None return "error", "Camera manager not available", None
device_info = self.camera_manager._find_camera_device(camera_name) device_info = self.camera_manager._find_camera_device(camera_name)
if not device_info: if not device_info:
return "disconnected", "Camera device not found", None return "disconnected", "Camera device not found", None
# Check if camera is already opened by another process # Check if camera is already opened by another process
if mvsdk.CameraIsOpened(device_info): if mvsdk.CameraIsOpened(device_info):
# Camera is opened - check if it's our recorder # Camera is opened - check if it's our recorder that's currently recording
recorder = self.camera_manager.camera_recorders.get(camera_name) recorder = self.camera_manager.camera_recorders.get(camera_name)
if recorder and recorder.hCamera: if recorder and recorder.hCamera and recorder.recording:
return "available", "Camera initialized and ready", self._get_device_info_dict(device_info) return "available", "Camera recording (in use by system)", self._get_device_info_dict(device_info)
else: else:
return "busy", "Camera opened by another process", self._get_device_info_dict(device_info) return "busy", "Camera opened by another process", self._get_device_info_dict(device_info)
# Try to initialize camera briefly to test availability # Try to initialize camera briefly to test availability
try: try:
hCamera = mvsdk.CameraInit(device_info, -1, -1) # Ensure SDK is initialized
ensure_sdk_initialized()
# Suppress output to avoid MVCAMAPI error messages during camera testing
with suppress_camera_errors():
hCamera = mvsdk.CameraInit(device_info, -1, -1)
# Quick test - try to get one frame # Quick test - try to get one frame
try: try:
mvsdk.CameraSetTriggerMode(hCamera, 0) mvsdk.CameraSetTriggerMode(hCamera, 0)
mvsdk.CameraPlay(hCamera) mvsdk.CameraPlay(hCamera)
# Try to capture with short timeout # Try to capture with short timeout
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(hCamera, 500) pRawData, FrameHead = mvsdk.CameraGetImageBuffer(hCamera, 500)
mvsdk.CameraReleaseImageBuffer(hCamera, pRawData) mvsdk.CameraReleaseImageBuffer(hCamera, pRawData)
# Success - camera is available # Success - camera is available
mvsdk.CameraUnInit(hCamera) mvsdk.CameraUnInit(hCamera)
return "available", "Camera test successful", self._get_device_info_dict(device_info) return "available", "Camera test successful", self._get_device_info_dict(device_info)
except mvsdk.CameraException as e: except mvsdk.CameraException as e:
mvsdk.CameraUnInit(hCamera) mvsdk.CameraUnInit(hCamera)
if e.error_code == mvsdk.CAMERA_STATUS_TIME_OUT: if e.error_code == mvsdk.CAMERA_STATUS_TIME_OUT:
return "available", "Camera available but slow response", self._get_device_info_dict(device_info) return "available", "Camera available but slow response", self._get_device_info_dict(device_info)
else: else:
return "error", f"Camera test failed: {e.message}", self._get_device_info_dict(device_info) return "error", f"Camera test failed: {e.message}", self._get_device_info_dict(device_info)
except mvsdk.CameraException as e: except mvsdk.CameraException as e:
return "error", f"Camera initialization failed: {e.message}", self._get_device_info_dict(device_info) return "error", f"Camera initialization failed: {e.message}", self._get_device_info_dict(device_info)
except Exception as e: except Exception as e:
return "error", f"Camera check failed: {str(e)}", None return "error", f"Camera check failed: {str(e)}", None
def _get_device_info_dict(self, device_info) -> Dict[str, Any]: def _get_device_info_dict(self, device_info) -> Dict[str, Any]:
"""Convert device info to dictionary""" """Convert device info to dictionary"""
try: try:
return { return {"friendly_name": device_info.GetFriendlyName(), "port_type": device_info.GetPortType(), "serial_number": getattr(device_info, "acSn", "Unknown"), "last_checked": time.time()}
"friendly_name": device_info.GetFriendlyName(),
"port_type": device_info.GetPortType(),
"serial_number": getattr(device_info, 'acSn', 'Unknown'),
"last_checked": time.time()
}
except Exception as e: except Exception as e:
self.logger.error(f"Error getting device info: {e}") self.logger.error(f"Error getting device info: {e}")
return {"error": str(e)} return {"error": str(e)}
def check_camera_now(self, camera_name: str) -> Dict[str, Any]: def check_camera_now(self, camera_name: str) -> Dict[str, Any]:
"""Manually check a specific camera status""" """Manually check a specific camera status"""
try: try:
status, details, device_info = self._perform_camera_check(camera_name) status, details, device_info = self._perform_camera_check(camera_name)
# Update state # Update state
self.state_manager.update_camera_status( self.state_manager.update_camera_status(name=camera_name, status=status, error=details if status == "error" else None, device_info=device_info)
name=camera_name,
status=status, return {"camera_name": camera_name, "status": status, "details": details, "device_info": device_info, "check_time": time.time()}
error=details if status == "error" else None,
device_info=device_info
)
return {
"camera_name": camera_name,
"status": status,
"details": details,
"device_info": device_info,
"check_time": time.time()
}
except Exception as e: except Exception as e:
error_msg = f"Manual camera check failed: {e}" error_msg = f"Manual camera check failed: {e}"
self.logger.error(error_msg) self.logger.error(error_msg)
return { return {"camera_name": camera_name, "status": "error", "details": error_msg, "device_info": None, "check_time": time.time()}
"camera_name": camera_name,
"status": "error",
"details": error_msg,
"device_info": None,
"check_time": time.time()
}
def check_all_cameras_now(self) -> Dict[str, Dict[str, Any]]: def check_all_cameras_now(self) -> Dict[str, Dict[str, Any]]:
"""Manually check all cameras""" """Manually check all cameras"""
results = {} results = {}
@@ -250,18 +246,11 @@ class CameraMonitor:
if camera_config.enabled: if camera_config.enabled:
results[camera_config.name] = self.check_camera_now(camera_config.name) results[camera_config.name] = self.check_camera_now(camera_config.name)
return results return results
def get_monitoring_stats(self) -> Dict[str, Any]: def get_monitoring_stats(self) -> Dict[str, Any]:
"""Get monitoring statistics""" """Get monitoring statistics"""
return { return {"running": self.running, "check_interval_seconds": self.check_interval, "total_checks": self.check_count, "error_count": self.error_count, "last_check_time": self.last_check_time, "success_rate": (self.check_count - self.error_count) / max(self.check_count, 1) * 100}
"running": self.running,
"check_interval_seconds": self.check_interval,
"total_checks": self.check_count,
"error_count": self.error_count,
"last_check_time": self.last_check_time,
"success_rate": (self.check_count - self.error_count) / max(self.check_count, 1) * 100
}
def is_running(self) -> bool: def is_running(self) -> bool:
"""Check if monitor is running""" """Check if monitor is running"""
return self.running return self.running

View File

@@ -11,18 +11,44 @@ import time
import logging import logging
import cv2 import cv2
import numpy as np import numpy as np
import contextlib
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
# Add python demo to path # Add python demo to path
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'python demo')) sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "python demo"))
import mvsdk import mvsdk
from ..core.config import CameraConfig from ..core.config import CameraConfig
from ..core.state_manager import StateManager from ..core.state_manager import StateManager
from ..core.events import EventSystem, publish_recording_started, publish_recording_stopped, publish_recording_error from ..core.events import EventSystem, publish_recording_started, publish_recording_stopped, publish_recording_error
from ..core.timezone_utils import now_atlanta, format_filename_timestamp from ..core.timezone_utils import now_atlanta, format_filename_timestamp
from .sdk_config import ensure_sdk_initialized
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
# Save original file descriptors
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
# Redirect stderr and stdout to devnull
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
os.close(devnull)
yield
finally:
# Restore original file descriptors
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)
class CameraRecorder: class CameraRecorder:
@@ -35,41 +61,46 @@ class CameraRecorder:
self.event_system = event_system self.event_system = event_system
self.storage_manager = storage_manager self.storage_manager = storage_manager
self.logger = logging.getLogger(f"{__name__}.{camera_config.name}") self.logger = logging.getLogger(f"{__name__}.{camera_config.name}")
# Camera handle and properties # Camera handle and properties
self.hCamera: Optional[int] = None self.hCamera: Optional[int] = None
self.cap = None self.cap = None
self.monoCamera = False self.monoCamera = False
self.frame_buffer = None self.frame_buffer = None
self.frame_buffer_size = 0 self.frame_buffer_size = 0
# Recording state # Recording state
self.recording = False self.recording = False
self.video_writer: Optional[cv2.VideoWriter] = None self.video_writer: Optional[cv2.VideoWriter] = None
self.output_filename: Optional[str] = None self.output_filename: Optional[str] = None
self.frame_count = 0 self.frame_count = 0
self.start_time: Optional[datetime] = None self.start_time: Optional[datetime] = None
# Threading # Threading
self._recording_thread: Optional[threading.Thread] = None self._recording_thread: Optional[threading.Thread] = None
self._stop_recording_event = threading.Event() self._stop_recording_event = threading.Event()
self._lock = threading.RLock() self._lock = threading.RLock()
# Initialize camera # Don't initialize camera immediately - use lazy initialization
self._initialize_camera() # Camera will be initialized when recording starts
self.logger.info(f"Camera recorder created for: {self.camera_config.name} (lazy initialization)")
def _initialize_camera(self) -> bool: def _initialize_camera(self) -> bool:
"""Initialize the camera with configured settings""" """Initialize the camera with configured settings"""
try: try:
self.logger.info(f"Initializing camera: {self.camera_config.name}") self.logger.info(f"Initializing camera: {self.camera_config.name}")
# Ensure SDK is initialized
ensure_sdk_initialized()
# Check if device_info is valid # Check if device_info is valid
if self.device_info is None: if self.device_info is None:
self.logger.error("No device info provided for camera initialization") self.logger.error("No device info provided for camera initialization")
return False return False
# Initialize camera # Initialize camera (suppress output to avoid MVCAMAPI error messages)
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1) with suppress_camera_errors():
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
self.logger.info("Camera initialized successfully") self.logger.info("Camera initialized successfully")
# Get camera capabilities # Get camera capabilities
@@ -104,9 +135,7 @@ class CameraRecorder:
# Allocate frame buffer based on bit depth # Allocate frame buffer based on bit depth
bytes_per_pixel = self._get_bytes_per_pixel() bytes_per_pixel = self._get_bytes_per_pixel()
self.frame_buffer_size = (self.cap.sResolutionRange.iWidthMax * self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
self.cap.sResolutionRange.iHeightMax *
bytes_per_pixel)
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16) self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
# Start camera # Start camera
@@ -124,7 +153,30 @@ class CameraRecorder:
except Exception as e: except Exception as e:
self.logger.error(f"Unexpected error during camera initialization: {e}") self.logger.error(f"Unexpected error during camera initialization: {e}")
return False return False
def _get_bytes_per_pixel(self) -> int:
"""Calculate bytes per pixel based on camera type and bit depth"""
if self.monoCamera:
# Monochrome camera
if self.camera_config.bit_depth >= 16:
return 2 # 16-bit mono
elif self.camera_config.bit_depth >= 12:
return 2 # 12-bit mono (stored in 16-bit)
elif self.camera_config.bit_depth >= 10:
return 2 # 10-bit mono (stored in 16-bit)
else:
return 1 # 8-bit mono
else:
# Color camera
if self.camera_config.bit_depth >= 16:
return 6 # 16-bit RGB (2 bytes × 3 channels)
elif self.camera_config.bit_depth >= 12:
return 6 # 12-bit RGB (stored as 16-bit)
elif self.camera_config.bit_depth >= 10:
return 6 # 10-bit RGB (stored as 16-bit)
else:
return 3 # 8-bit RGB
def _configure_camera_settings(self) -> None: def _configure_camera_settings(self) -> None:
"""Configure camera settings from config""" """Configure camera settings from config"""
try: try:
@@ -174,8 +226,7 @@ class CameraRecorder:
if not self.monoCamera: if not self.monoCamera:
mvsdk.CameraSetSaturation(self.hCamera, self.camera_config.saturation) mvsdk.CameraSetSaturation(self.hCamera, self.camera_config.saturation)
self.logger.info(f"Image quality configured - Sharpness: {self.camera_config.sharpness}, " self.logger.info(f"Image quality configured - Sharpness: {self.camera_config.sharpness}, " f"Contrast: {self.camera_config.contrast}, Gamma: {self.camera_config.gamma}")
f"Contrast: {self.camera_config.contrast}, Gamma: {self.camera_config.gamma}")
except Exception as e: except Exception as e:
self.logger.warning(f"Error configuring image quality: {e}") self.logger.warning(f"Error configuring image quality: {e}")
@@ -194,8 +245,7 @@ class CameraRecorder:
else: else:
mvsdk.CameraSetDenoise3DParams(self.hCamera, False, 2, None) mvsdk.CameraSetDenoise3DParams(self.hCamera, False, 2, None)
self.logger.info(f"Noise reduction configured - Filter: {self.camera_config.noise_filter_enabled}, " self.logger.info(f"Noise reduction configured - Filter: {self.camera_config.noise_filter_enabled}, " f"3D Denoise: {self.camera_config.denoise_3d_enabled}")
f"3D Denoise: {self.camera_config.denoise_3d_enabled}")
except Exception as e: except Exception as e:
self.logger.warning(f"Error configuring noise reduction: {e}") self.logger.warning(f"Error configuring noise reduction: {e}")
@@ -210,8 +260,7 @@ class CameraRecorder:
if not self.camera_config.auto_white_balance: if not self.camera_config.auto_white_balance:
mvsdk.CameraSetPresetClrTemp(self.hCamera, self.camera_config.color_temperature_preset) mvsdk.CameraSetPresetClrTemp(self.hCamera, self.camera_config.color_temperature_preset)
self.logger.info(f"Color settings configured - Auto WB: {self.camera_config.auto_white_balance}, " self.logger.info(f"Color settings configured - Auto WB: {self.camera_config.auto_white_balance}, " f"Color Temp Preset: {self.camera_config.color_temperature_preset}")
f"Color Temp Preset: {self.camera_config.color_temperature_preset}")
except Exception as e: except Exception as e:
self.logger.warning(f"Error configuring color settings: {e}") self.logger.warning(f"Error configuring color settings: {e}")
@@ -225,61 +274,104 @@ class CameraRecorder:
# Set light frequency (0=50Hz, 1=60Hz) # Set light frequency (0=50Hz, 1=60Hz)
mvsdk.CameraSetLightFrequency(self.hCamera, self.camera_config.light_frequency) mvsdk.CameraSetLightFrequency(self.hCamera, self.camera_config.light_frequency)
# Configure HDR if enabled # Configure HDR if enabled (check if HDR functions are available)
if self.camera_config.hdr_enabled: try:
mvsdk.CameraSetHDR(self.hCamera, 1) # Enable HDR if self.camera_config.hdr_enabled:
mvsdk.CameraSetHDRGainMode(self.hCamera, self.camera_config.hdr_gain_mode) mvsdk.CameraSetHDR(self.hCamera, 1) # Enable HDR
self.logger.info(f"HDR enabled with gain mode: {self.camera_config.hdr_gain_mode}") mvsdk.CameraSetHDRGainMode(self.hCamera, self.camera_config.hdr_gain_mode)
else: self.logger.info(f"HDR enabled with gain mode: {self.camera_config.hdr_gain_mode}")
mvsdk.CameraSetHDR(self.hCamera, 0) # Disable HDR else:
mvsdk.CameraSetHDR(self.hCamera, 0) # Disable HDR
except AttributeError:
self.logger.info("HDR functions not available in this SDK version, skipping HDR configuration")
self.logger.info(f"Advanced settings configured - Anti-flicker: {self.camera_config.anti_flicker_enabled}, " self.logger.info(f"Advanced settings configured - Anti-flicker: {self.camera_config.anti_flicker_enabled}, " f"Light Freq: {self.camera_config.light_frequency}Hz, HDR: {self.camera_config.hdr_enabled}")
f"Light Freq: {self.camera_config.light_frequency}Hz, HDR: {self.camera_config.hdr_enabled}")
except Exception as e: except Exception as e:
self.logger.warning(f"Error configuring advanced settings: {e}") self.logger.warning(f"Error configuring advanced settings: {e}")
def update_camera_settings(self, exposure_ms: Optional[float] = None, gain: Optional[float] = None, target_fps: Optional[float] = None) -> bool:
"""Update camera settings dynamically"""
if not self.hCamera:
self.logger.error("Camera not initialized")
return False
try:
settings_updated = False
# Update exposure if provided
if exposure_ms is not None:
mvsdk.CameraSetAeState(self.hCamera, 0) # Disable auto exposure
exposure_us = int(exposure_ms * 1000) # Convert ms to microseconds
mvsdk.CameraSetExposureTime(self.hCamera, exposure_us)
self.camera_config.exposure_ms = exposure_ms
self.logger.info(f"Updated exposure time: {exposure_ms}ms")
settings_updated = True
# Update gain if provided
if gain is not None:
gain_value = int(gain * 100) # Convert to camera units
mvsdk.CameraSetAnalogGain(self.hCamera, gain_value)
self.camera_config.gain = gain
self.logger.info(f"Updated gain: {gain}x")
settings_updated = True
# Update target FPS if provided
if target_fps is not None:
self.camera_config.target_fps = target_fps
self.logger.info(f"Updated target FPS: {target_fps}")
settings_updated = True
return settings_updated
except Exception as e:
self.logger.error(f"Error updating camera settings: {e}")
return False
def start_recording(self, filename: str) -> bool: def start_recording(self, filename: str) -> bool:
"""Start video recording""" """Start video recording"""
with self._lock: with self._lock:
if self.recording: if self.recording:
self.logger.warning("Already recording!") self.logger.warning("Already recording!")
return False return False
# Initialize camera if not already initialized (lazy initialization)
if not self.hCamera: if not self.hCamera:
self.logger.error("Camera not initialized") self.logger.info("Camera not initialized, initializing now...")
return False if not self._initialize_camera():
self.logger.error("Failed to initialize camera for recording")
return False
try: try:
# Prepare output path # Prepare output path
output_path = os.path.join(self.camera_config.storage_path, filename) output_path = os.path.join(self.camera_config.storage_path, filename)
Path(self.camera_config.storage_path).mkdir(parents=True, exist_ok=True) Path(self.camera_config.storage_path).mkdir(parents=True, exist_ok=True)
# Test camera capture before starting recording # Test camera capture before starting recording
if not self._test_camera_capture(): if not self._test_camera_capture():
self.logger.error("Camera capture test failed") self.logger.error("Camera capture test failed")
return False return False
# Initialize recording state # Initialize recording state
self.output_filename = output_path self.output_filename = output_path
self.frame_count = 0 self.frame_count = 0
self.start_time = now_atlanta() # Use Atlanta timezone self.start_time = now_atlanta() # Use Atlanta timezone
self._stop_recording_event.clear() self._stop_recording_event.clear()
# Start recording thread # Start recording thread
self._recording_thread = threading.Thread(target=self._recording_loop, daemon=True) self._recording_thread = threading.Thread(target=self._recording_loop, daemon=True)
self._recording_thread.start() self._recording_thread.start()
# Update state # Update state
self.recording = True self.recording = True
recording_id = self.state_manager.start_recording(self.camera_config.name, output_path) recording_id = self.state_manager.start_recording(self.camera_config.name, output_path)
# Publish event # Publish event
publish_recording_started(self.camera_config.name, output_path) publish_recording_started(self.camera_config.name, output_path)
self.logger.info(f"Started recording to: {output_path}") self.logger.info(f"Started recording to: {output_path}")
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error starting recording: {e}") self.logger.error(f"Error starting recording: {e}")
publish_recording_error(self.camera_config.name, str(e)) publish_recording_error(self.camera_config.name, str(e))
@@ -329,11 +421,11 @@ class CameraRecorder:
self.state_manager.stop_recording(self.output_filename, file_size, self.frame_count) self.state_manager.stop_recording(self.output_filename, file_size, self.frame_count)
# Publish event # Publish event
publish_recording_stopped( publish_recording_stopped(self.camera_config.name, self.output_filename or "unknown", duration)
self.camera_config.name,
self.output_filename or "unknown", # Clean up camera resources after recording (lazy cleanup)
duration self._cleanup_camera()
) self.logger.info("Camera resources cleaned up after recording")
self.logger.info(f"Stopped recording - Duration: {duration:.1f}s, Frames: {self.frame_count}") self.logger.info(f"Stopped recording - Duration: {duration:.1f}s, Frames: {self.frame_count}")
return True return True
@@ -402,18 +494,13 @@ class CameraRecorder:
mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData) mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData)
# Set up video writer # Set up video writer
fourcc = cv2.VideoWriter_fourcc(*'XVID') fourcc = cv2.VideoWriter_fourcc(*"XVID")
frame_size = (FrameHead.iWidth, FrameHead.iHeight) frame_size = (FrameHead.iWidth, FrameHead.iHeight)
# Use 30 FPS for video writer if target_fps is 0 (unlimited) # 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 video_fps = self.camera_config.target_fps if self.camera_config.target_fps > 0 else 30.0
self.video_writer = cv2.VideoWriter( self.video_writer = cv2.VideoWriter(self.output_filename, fourcc, video_fps, frame_size)
self.output_filename,
fourcc,
video_fps,
frame_size
)
if not self.video_writer.isOpened(): if not self.video_writer.isOpened():
self.logger.error(f"Failed to open video writer for {self.output_filename}") self.logger.error(f"Failed to open video writer for {self.output_filename}")
@@ -432,15 +519,34 @@ class CameraRecorder:
# Convert the frame buffer memory address to a proper buffer # Convert the frame buffer memory address to a proper buffer
# that numpy can work with using mvsdk.c_ubyte # that numpy can work with using mvsdk.c_ubyte
frame_data_buffer = (mvsdk.c_ubyte * frame_head.uBytes).from_address(self.frame_buffer) frame_data_buffer = (mvsdk.c_ubyte * frame_head.uBytes).from_address(self.frame_buffer)
frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint8)
if self.monoCamera: # Handle different bit depths
# Monochrome camera - convert to BGR if self.camera_config.bit_depth > 8:
frame = frame_data.reshape((frame_head.iHeight, frame_head.iWidth)) # For >8-bit, data is stored as 16-bit values
frame_bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint16)
if self.monoCamera:
# Monochrome camera - convert to 8-bit BGR for video
frame = frame_data.reshape((frame_head.iHeight, frame_head.iWidth))
# Scale down to 8-bit (simple right shift)
frame_8bit = (frame >> (self.camera_config.bit_depth - 8)).astype(np.uint8)
frame_bgr = cv2.cvtColor(frame_8bit, cv2.COLOR_GRAY2BGR)
else:
# Color camera - convert to 8-bit BGR
frame = frame_data.reshape((frame_head.iHeight, frame_head.iWidth, 3))
# Scale down to 8-bit
frame_bgr = (frame >> (self.camera_config.bit_depth - 8)).astype(np.uint8)
else: else:
# Color camera - already in BGR format # 8-bit data
frame_bgr = frame_data.reshape((frame_head.iHeight, frame_head.iWidth, 3)) frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint8)
if self.monoCamera:
# Monochrome camera - convert to BGR
frame = frame_data.reshape((frame_head.iHeight, frame_head.iWidth))
frame_bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
else:
# Color camera - already in BGR format
frame_bgr = frame_data.reshape((frame_head.iHeight, frame_head.iWidth, 3))
return frame_bgr return frame_bgr
@@ -460,6 +566,175 @@ class CameraRecorder:
except Exception as e: except Exception as e:
self.logger.error(f"Error during recording cleanup: {e}") self.logger.error(f"Error during recording cleanup: {e}")
def test_connection(self) -> bool:
"""Test camera connection"""
try:
if self.hCamera is None:
self.logger.error("Camera not initialized")
return False
# Test connection using SDK function
result = mvsdk.CameraConnectTest(self.hCamera)
if result == 0: # CAMERA_STATUS_SUCCESS
self.logger.info("Camera connection test passed")
return True
else:
self.logger.error(f"Camera connection test failed with code: {result}")
return False
except Exception as e:
self.logger.error(f"Error testing camera connection: {e}")
return False
def reconnect(self) -> bool:
"""Attempt to reconnect to the camera"""
try:
if self.hCamera is None:
self.logger.error("Camera not initialized, cannot reconnect")
return False
self.logger.info("Attempting to reconnect camera...")
# Stop any ongoing operations
if self.recording:
self.logger.info("Stopping recording before reconnect")
self.stop_recording()
# Attempt reconnection using SDK function
result = mvsdk.CameraReConnect(self.hCamera)
if result == 0: # CAMERA_STATUS_SUCCESS
self.logger.info("Camera reconnected successfully")
# Restart camera if it was playing
try:
mvsdk.CameraPlay(self.hCamera)
self.logger.info("Camera restarted after reconnection")
except Exception as e:
self.logger.warning(f"Failed to restart camera after reconnection: {e}")
return True
else:
self.logger.error(f"Camera reconnection failed with code: {result}")
return False
except Exception as e:
self.logger.error(f"Error during camera reconnection: {e}")
return False
def restart_grab(self) -> bool:
"""Restart the camera grab process"""
try:
if self.hCamera is None:
self.logger.error("Camera not initialized")
return False
self.logger.info("Restarting camera grab process...")
# Stop any ongoing recording
if self.recording:
self.logger.info("Stopping recording before restart")
self.stop_recording()
# Restart grab using SDK function
result = mvsdk.CameraRestartGrab(self.hCamera)
if result == 0: # CAMERA_STATUS_SUCCESS
self.logger.info("Camera grab restarted successfully")
return True
else:
self.logger.error(f"Camera grab restart failed with code: {result}")
return False
except Exception as e:
self.logger.error(f"Error restarting camera grab: {e}")
return False
def reset_timestamp(self) -> bool:
"""Reset camera timestamp"""
try:
if self.hCamera is None:
self.logger.error("Camera not initialized")
return False
self.logger.info("Resetting camera timestamp...")
result = mvsdk.CameraRstTimeStamp(self.hCamera)
if result == 0: # CAMERA_STATUS_SUCCESS
self.logger.info("Camera timestamp reset successfully")
return True
else:
self.logger.error(f"Camera timestamp reset failed with code: {result}")
return False
except Exception as e:
self.logger.error(f"Error resetting camera timestamp: {e}")
return False
def full_reset(self) -> bool:
"""Perform a full camera reset (uninitialize and reinitialize)"""
try:
self.logger.info("Performing full camera reset...")
# Stop any ongoing recording
if self.recording:
self.logger.info("Stopping recording before reset")
self.stop_recording()
# Store device info for reinitialization
device_info = self.device_info
# Cleanup current camera
self._cleanup_camera()
# Wait a moment
time.sleep(1)
# Reinitialize camera
self.device_info = device_info
success = self._initialize_camera()
if success:
self.logger.info("Full camera reset completed successfully")
return True
else:
self.logger.error("Full camera reset failed during reinitialization")
return False
except Exception as e:
self.logger.error(f"Error during full camera reset: {e}")
return False
def _cleanup_camera(self) -> None:
"""Clean up camera resources"""
try:
# Stop camera if running
if self.hCamera is not None:
try:
mvsdk.CameraStop(self.hCamera)
except:
pass # Ignore errors during stop
# Uninitialize camera
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass # Ignore errors during uninit
self.hCamera = None
# Free frame buffer
if self.frame_buffer is not None:
try:
mvsdk.CameraAlignFree(self.frame_buffer)
except:
pass # Ignore errors during free
self.frame_buffer = None
self.logger.info("Camera resources cleaned up")
except Exception as e:
self.logger.error(f"Error during camera cleanup: {e}")
def cleanup(self) -> None: def cleanup(self) -> None:
"""Clean up camera resources""" """Clean up camera resources"""
try: try:
@@ -488,12 +763,4 @@ class CameraRecorder:
def get_status(self) -> Dict[str, Any]: def get_status(self) -> Dict[str, Any]:
"""Get recorder status""" """Get recorder status"""
return { return {"camera_name": self.camera_config.name, "is_recording": self.recording, "current_file": self.output_filename, "frame_count": self.frame_count, "start_time": self.start_time.isoformat() if self.start_time else None, "camera_initialized": self.hCamera is not None, "storage_path": self.camera_config.storage_path}
"camera_name": self.camera_config.name,
"is_recording": self.recording,
"current_file": self.output_filename,
"frame_count": self.frame_count,
"start_time": self.start_time.isoformat() if self.start_time else None,
"camera_initialized": self.hCamera is not None,
"storage_path": self.camera_config.storage_path
}

View File

@@ -0,0 +1,89 @@
"""
SDK Configuration for the USDA Vision Camera System.
This module handles SDK initialization and configuration to suppress error messages.
"""
import sys
import os
import logging
# Add python demo to path
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "python demo"))
import mvsdk
logger = logging.getLogger(__name__)
# Global flag to track SDK initialization
_sdk_initialized = False
def initialize_sdk_with_suppression():
"""Initialize the camera SDK with error suppression"""
global _sdk_initialized
if _sdk_initialized:
return True
try:
# Initialize SDK with English language
result = mvsdk.CameraSdkInit(1)
if result == 0:
logger.info("Camera SDK initialized successfully")
# Try to set system options to suppress logging
try:
# These are common options that might control logging
# We'll try them and ignore failures since they might not be supported
# Try to disable debug output
try:
mvsdk.CameraSetSysOption("DebugLevel", "0")
except:
pass
# Try to disable console output
try:
mvsdk.CameraSetSysOption("ConsoleOutput", "0")
except:
pass
# Try to disable error logging
try:
mvsdk.CameraSetSysOption("ErrorLog", "0")
except:
pass
# Try to set log level to none
try:
mvsdk.CameraSetSysOption("LogLevel", "0")
except:
pass
# Try to disable verbose mode
try:
mvsdk.CameraSetSysOption("Verbose", "0")
except:
pass
logger.debug("Attempted to configure SDK logging options")
except Exception as e:
logger.debug(f"Could not configure SDK logging options: {e}")
_sdk_initialized = True
return True
else:
logger.error(f"SDK initialization failed with code: {result}")
return False
except Exception as e:
logger.error(f"SDK initialization failed: {e}")
return False
def ensure_sdk_initialized():
"""Ensure the SDK is initialized before camera operations"""
if not _sdk_initialized:
return initialize_sdk_with_suppression()
return True

View File

@@ -9,12 +9,13 @@ import threading
import logging import logging
from typing import Dict, Optional, List, Any from typing import Dict, Optional, List, Any
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime, timedelta
from enum import Enum from enum import Enum
class MachineState(Enum): class MachineState(Enum):
"""Machine states""" """Machine states"""
UNKNOWN = "unknown" UNKNOWN = "unknown"
ON = "on" ON = "on"
OFF = "off" OFF = "off"
@@ -23,6 +24,7 @@ class MachineState(Enum):
class CameraStatus(Enum): class CameraStatus(Enum):
"""Camera status""" """Camera status"""
UNKNOWN = "unknown" UNKNOWN = "unknown"
AVAILABLE = "available" AVAILABLE = "available"
BUSY = "busy" BUSY = "busy"
@@ -32,6 +34,7 @@ class CameraStatus(Enum):
class RecordingState(Enum): class RecordingState(Enum):
"""Recording states""" """Recording states"""
IDLE = "idle" IDLE = "idle"
RECORDING = "recording" RECORDING = "recording"
STOPPING = "stopping" STOPPING = "stopping"
@@ -41,6 +44,7 @@ class RecordingState(Enum):
@dataclass @dataclass
class MachineInfo: class MachineInfo:
"""Machine state information""" """Machine state information"""
name: str name: str
state: MachineState = MachineState.UNKNOWN state: MachineState = MachineState.UNKNOWN
last_updated: datetime = field(default_factory=datetime.now) last_updated: datetime = field(default_factory=datetime.now)
@@ -48,9 +52,22 @@ class MachineInfo:
mqtt_topic: Optional[str] = None mqtt_topic: Optional[str] = None
@dataclass
class MQTTEvent:
"""MQTT event information for history tracking"""
machine_name: str
topic: str
payload: str
normalized_state: str
timestamp: datetime = field(default_factory=datetime.now)
message_number: int = 0
@dataclass @dataclass
class CameraInfo: class CameraInfo:
"""Camera state information""" """Camera state information"""
name: str name: str
status: CameraStatus = CameraStatus.UNKNOWN status: CameraStatus = CameraStatus.UNKNOWN
last_checked: datetime = field(default_factory=datetime.now) last_checked: datetime = field(default_factory=datetime.now)
@@ -64,6 +81,7 @@ class CameraInfo:
@dataclass @dataclass
class RecordingInfo: class RecordingInfo:
"""Recording session information""" """Recording session information"""
camera_name: str camera_name: str
filename: str filename: str
start_time: datetime start_time: datetime
@@ -76,21 +94,26 @@ class RecordingInfo:
class StateManager: class StateManager:
"""Thread-safe state manager for the entire system""" """Thread-safe state manager for the entire system"""
def __init__(self): def __init__(self):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self._lock = threading.RLock() self._lock = threading.RLock()
# State dictionaries # State dictionaries
self._machines: Dict[str, MachineInfo] = {} self._machines: Dict[str, MachineInfo] = {}
self._cameras: Dict[str, CameraInfo] = {} self._cameras: Dict[str, CameraInfo] = {}
self._recordings: Dict[str, RecordingInfo] = {} # Key: recording_id (filename) self._recordings: Dict[str, RecordingInfo] = {} # Key: recording_id (filename)
# MQTT event history
self._mqtt_events: List[MQTTEvent] = []
self._mqtt_event_counter = 0
self._max_mqtt_events = 100 # Keep last 100 events
# System state # System state
self._mqtt_connected = False self._mqtt_connected = False
self._system_started = False self._system_started = False
self._last_mqtt_message_time: Optional[datetime] = None self._last_mqtt_message_time: Optional[datetime] = None
# Machine state management # Machine state management
def update_machine_state(self, name: str, state: str, message: Optional[str] = None, topic: Optional[str] = None) -> bool: def update_machine_state(self, name: str, state: str, message: Optional[str] = None, topic: Optional[str] = None) -> bool:
"""Update machine state""" """Update machine state"""
@@ -99,11 +122,11 @@ class StateManager:
except ValueError: except ValueError:
self.logger.warning(f"Invalid machine state: {state}") self.logger.warning(f"Invalid machine state: {state}")
machine_state = MachineState.UNKNOWN machine_state = MachineState.UNKNOWN
with self._lock: with self._lock:
if name not in self._machines: if name not in self._machines:
self._machines[name] = MachineInfo(name=name, mqtt_topic=topic) self._machines[name] = MachineInfo(name=name, mqtt_topic=topic)
machine = self._machines[name] machine = self._machines[name]
old_state = machine.state old_state = machine.state
machine.state = machine_state machine.state = machine_state
@@ -111,20 +134,47 @@ class StateManager:
machine.last_message = message machine.last_message = message
if topic: if topic:
machine.mqtt_topic = topic machine.mqtt_topic = topic
self.logger.info(f"Machine {name} state: {old_state.value} -> {machine_state.value}") self.logger.info(f"Machine {name} state: {old_state.value} -> {machine_state.value}")
return old_state != machine_state return old_state != machine_state
def get_machine_state(self, name: str) -> Optional[MachineInfo]: def get_machine_state(self, name: str) -> Optional[MachineInfo]:
"""Get machine state""" """Get machine state"""
with self._lock: with self._lock:
return self._machines.get(name) return self._machines.get(name)
def get_all_machines(self) -> Dict[str, MachineInfo]: def get_all_machines(self) -> Dict[str, MachineInfo]:
"""Get all machine states""" """Get all machine states"""
with self._lock: with self._lock:
return self._machines.copy() return self._machines.copy()
# MQTT event management
def add_mqtt_event(self, machine_name: str, topic: str, payload: str, normalized_state: str) -> None:
"""Add an MQTT event to the history"""
with self._lock:
self._mqtt_event_counter += 1
event = MQTTEvent(machine_name=machine_name, topic=topic, payload=payload, normalized_state=normalized_state, timestamp=datetime.now(), message_number=self._mqtt_event_counter)
self._mqtt_events.append(event)
# Keep only the last N events
if len(self._mqtt_events) > self._max_mqtt_events:
self._mqtt_events.pop(0)
self.logger.debug(f"Added MQTT event #{self._mqtt_event_counter}: {machine_name} -> {normalized_state}")
def get_recent_mqtt_events(self, limit: int = 5) -> List[MQTTEvent]:
"""Get the most recent MQTT events"""
with self._lock:
# Return the last 'limit' events in reverse chronological order (newest first)
return list(reversed(self._mqtt_events[-limit:]))
def get_mqtt_event_count(self) -> int:
"""Get total number of MQTT events processed"""
with self._lock:
return self._mqtt_event_counter
# Camera state management # Camera state management
def update_camera_status(self, name: str, status: str, error: Optional[str] = None, device_info: Optional[Dict] = None) -> bool: def update_camera_status(self, name: str, status: str, error: Optional[str] = None, device_info: Optional[Dict] = None) -> bool:
"""Update camera status""" """Update camera status"""
@@ -133,11 +183,11 @@ class StateManager:
except ValueError: except ValueError:
self.logger.warning(f"Invalid camera status: {status}") self.logger.warning(f"Invalid camera status: {status}")
camera_status = CameraStatus.UNKNOWN camera_status = CameraStatus.UNKNOWN
with self._lock: with self._lock:
if name not in self._cameras: if name not in self._cameras:
self._cameras[name] = CameraInfo(name=name) self._cameras[name] = CameraInfo(name=name)
camera = self._cameras[name] camera = self._cameras[name]
old_status = camera.status old_status = camera.status
camera.status = camera_status camera.status = camera_status
@@ -145,113 +195,106 @@ class StateManager:
camera.last_error = error camera.last_error = error
if device_info: if device_info:
camera.device_info = device_info camera.device_info = device_info
if old_status != camera_status: if old_status != camera_status:
self.logger.info(f"Camera {name} status: {old_status.value} -> {camera_status.value}") self.logger.info(f"Camera {name} status: {old_status.value} -> {camera_status.value}")
return True return True
return False return False
def set_camera_recording(self, name: str, recording: bool, filename: Optional[str] = None) -> None: def set_camera_recording(self, name: str, recording: bool, filename: Optional[str] = None) -> None:
"""Set camera recording state""" """Set camera recording state"""
with self._lock: with self._lock:
if name not in self._cameras: if name not in self._cameras:
self._cameras[name] = CameraInfo(name=name) self._cameras[name] = CameraInfo(name=name)
camera = self._cameras[name] camera = self._cameras[name]
camera.is_recording = recording camera.is_recording = recording
camera.current_recording_file = filename camera.current_recording_file = filename
if recording and filename: if recording and filename:
camera.recording_start_time = datetime.now() camera.recording_start_time = datetime.now()
self.logger.info(f"Camera {name} started recording: {filename}") self.logger.info(f"Camera {name} started recording: {filename}")
elif not recording: elif not recording:
camera.recording_start_time = None camera.recording_start_time = None
self.logger.info(f"Camera {name} stopped recording") self.logger.info(f"Camera {name} stopped recording")
def get_camera_status(self, name: str) -> Optional[CameraInfo]: def get_camera_status(self, name: str) -> Optional[CameraInfo]:
"""Get camera status""" """Get camera status"""
with self._lock: with self._lock:
return self._cameras.get(name) return self._cameras.get(name)
def get_all_cameras(self) -> Dict[str, CameraInfo]: def get_all_cameras(self) -> Dict[str, CameraInfo]:
"""Get all camera statuses""" """Get all camera statuses"""
with self._lock: with self._lock:
return self._cameras.copy() return self._cameras.copy()
# Recording management # Recording management
def start_recording(self, camera_name: str, filename: str) -> str: def start_recording(self, camera_name: str, filename: str) -> str:
"""Start a new recording session""" """Start a new recording session"""
recording_id = filename # Use filename as recording ID recording_id = filename # Use filename as recording ID
with self._lock: with self._lock:
recording = RecordingInfo( recording = RecordingInfo(camera_name=camera_name, filename=filename, start_time=datetime.now())
camera_name=camera_name,
filename=filename,
start_time=datetime.now()
)
self._recordings[recording_id] = recording self._recordings[recording_id] = recording
# Update camera state # Update camera state
self.set_camera_recording(camera_name, True, filename) self.set_camera_recording(camera_name, True, filename)
self.logger.info(f"Started recording session: {recording_id}") self.logger.info(f"Started recording session: {recording_id}")
return recording_id return recording_id
def stop_recording(self, recording_id: str, file_size: Optional[int] = None, frame_count: Optional[int] = None) -> bool: def stop_recording(self, recording_id: str, file_size: Optional[int] = None, frame_count: Optional[int] = None) -> bool:
"""Stop a recording session""" """Stop a recording session"""
with self._lock: with self._lock:
if recording_id not in self._recordings: if recording_id not in self._recordings:
self.logger.warning(f"Recording session not found: {recording_id}") self.logger.warning(f"Recording session not found: {recording_id}")
return False return False
recording = self._recordings[recording_id] recording = self._recordings[recording_id]
recording.state = RecordingState.IDLE recording.state = RecordingState.IDLE
recording.end_time = datetime.now() recording.end_time = datetime.now()
recording.file_size_bytes = file_size recording.file_size_bytes = file_size
recording.frame_count = frame_count recording.frame_count = frame_count
# Update camera state # Update camera state
self.set_camera_recording(recording.camera_name, False) self.set_camera_recording(recording.camera_name, False)
duration = (recording.end_time - recording.start_time).total_seconds() duration = (recording.end_time - recording.start_time).total_seconds()
self.logger.info(f"Stopped recording session: {recording_id} (duration: {duration:.1f}s)") self.logger.info(f"Stopped recording session: {recording_id} (duration: {duration:.1f}s)")
return True return True
def set_recording_error(self, recording_id: str, error_message: str) -> bool: def set_recording_error(self, recording_id: str, error_message: str) -> bool:
"""Set recording error state""" """Set recording error state"""
with self._lock: with self._lock:
if recording_id not in self._recordings: if recording_id not in self._recordings:
return False return False
recording = self._recordings[recording_id] recording = self._recordings[recording_id]
recording.state = RecordingState.ERROR recording.state = RecordingState.ERROR
recording.error_message = error_message recording.error_message = error_message
recording.end_time = datetime.now() recording.end_time = datetime.now()
# Update camera state # Update camera state
self.set_camera_recording(recording.camera_name, False) self.set_camera_recording(recording.camera_name, False)
self.logger.error(f"Recording error for {recording_id}: {error_message}") self.logger.error(f"Recording error for {recording_id}: {error_message}")
return True return True
def get_recording(self, recording_id: str) -> Optional[RecordingInfo]: def get_recording(self, recording_id: str) -> Optional[RecordingInfo]:
"""Get recording information""" """Get recording information"""
with self._lock: with self._lock:
return self._recordings.get(recording_id) return self._recordings.get(recording_id)
def get_all_recordings(self) -> Dict[str, RecordingInfo]: def get_all_recordings(self) -> Dict[str, RecordingInfo]:
"""Get all recording sessions""" """Get all recording sessions"""
with self._lock: with self._lock:
return self._recordings.copy() return self._recordings.copy()
def get_active_recordings(self) -> Dict[str, RecordingInfo]: def get_active_recordings(self) -> Dict[str, RecordingInfo]:
"""Get currently active recordings""" """Get currently active recordings"""
with self._lock: with self._lock:
return { return {rid: recording for rid, recording in self._recordings.items() if recording.state == RecordingState.RECORDING}
rid: recording for rid, recording in self._recordings.items()
if recording.state == RecordingState.RECORDING
}
# System state management # System state management
def set_mqtt_connected(self, connected: bool) -> None: def set_mqtt_connected(self, connected: bool) -> None:
"""Set MQTT connection state""" """Set MQTT connection state"""
@@ -260,31 +303,31 @@ class StateManager:
self._mqtt_connected = connected self._mqtt_connected = connected
if connected: if connected:
self._last_mqtt_message_time = datetime.now() self._last_mqtt_message_time = datetime.now()
if old_state != connected: if old_state != connected:
self.logger.info(f"MQTT connection: {'connected' if connected else 'disconnected'}") self.logger.info(f"MQTT connection: {'connected' if connected else 'disconnected'}")
def is_mqtt_connected(self) -> bool: def is_mqtt_connected(self) -> bool:
"""Check if MQTT is connected""" """Check if MQTT is connected"""
with self._lock: with self._lock:
return self._mqtt_connected return self._mqtt_connected
def update_mqtt_activity(self) -> None: def update_mqtt_activity(self) -> None:
"""Update last MQTT message time""" """Update last MQTT message time"""
with self._lock: with self._lock:
self._last_mqtt_message_time = datetime.now() self._last_mqtt_message_time = datetime.now()
def set_system_started(self, started: bool) -> None: def set_system_started(self, started: bool) -> None:
"""Set system started state""" """Set system started state"""
with self._lock: with self._lock:
self._system_started = started self._system_started = started
self.logger.info(f"System {'started' if started else 'stopped'}") self.logger.info(f"System {'started' if started else 'stopped'}")
def is_system_started(self) -> bool: def is_system_started(self) -> bool:
"""Check if system is started""" """Check if system is started"""
with self._lock: with self._lock:
return self._system_started return self._system_started
# Utility methods # Utility methods
def get_system_summary(self) -> Dict[str, Any]: def get_system_summary(self) -> Dict[str, Any]:
"""Get a summary of the entire system state""" """Get a summary of the entire system state"""
@@ -293,36 +336,28 @@ class StateManager:
"system_started": self._system_started, "system_started": self._system_started,
"mqtt_connected": self._mqtt_connected, "mqtt_connected": self._mqtt_connected,
"last_mqtt_message": self._last_mqtt_message_time.isoformat() if self._last_mqtt_message_time else None, "last_mqtt_message": self._last_mqtt_message_time.isoformat() if self._last_mqtt_message_time else None,
"machines": {name: { "machines": {name: {"state": machine.state.value, "last_updated": machine.last_updated.isoformat()} for name, machine in self._machines.items()},
"state": machine.state.value, "cameras": {name: {"status": camera.status.value, "is_recording": camera.is_recording, "last_checked": camera.last_checked.isoformat()} for name, camera in self._cameras.items()},
"last_updated": machine.last_updated.isoformat()
} for name, machine in self._machines.items()},
"cameras": {name: {
"status": camera.status.value,
"is_recording": camera.is_recording,
"last_checked": camera.last_checked.isoformat()
} for name, camera in self._cameras.items()},
"active_recordings": len(self.get_active_recordings()), "active_recordings": len(self.get_active_recordings()),
"total_recordings": len(self._recordings) "total_recordings": len(self._recordings),
} }
def cleanup_old_recordings(self, max_age_hours: int = 24) -> int: def cleanup_old_recordings(self, max_age_hours: int = 24) -> int:
"""Clean up old recording entries from memory""" """Clean up old recording entries from memory"""
cutoff_time = datetime.now() - datetime.timedelta(hours=max_age_hours) cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
removed_count = 0 removed_count = 0
with self._lock: with self._lock:
to_remove = [] to_remove = []
for recording_id, recording in self._recordings.items(): for recording_id, recording in self._recordings.items():
if (recording.state != RecordingState.RECORDING and if recording.state != RecordingState.RECORDING and recording.end_time and recording.end_time < cutoff_time:
recording.end_time and recording.end_time < cutoff_time):
to_remove.append(recording_id) to_remove.append(recording_id)
for recording_id in to_remove: for recording_id in to_remove:
del self._recordings[recording_id] del self._recordings[recording_id]
removed_count += 1 removed_count += 1
if removed_count > 0: if removed_count > 0:
self.logger.info(f"Cleaned up {removed_count} old recording entries") self.logger.info(f"Cleaned up {removed_count} old recording entries")
return removed_count return removed_count

View File

@@ -7,7 +7,7 @@ This module provides MQTT connectivity and message handling for machine state up
import threading import threading
import time import time
import logging import logging
from typing import Dict, Optional, Callable, List from typing import Dict, Optional, Any
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from ..core.config import Config, MQTTConfig from ..core.config import Config, MQTTConfig
@@ -18,207 +18,219 @@ from .handlers import MQTTMessageHandler
class MQTTClient: class MQTTClient:
"""MQTT client for receiving machine state updates""" """MQTT client for receiving machine state updates"""
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem): def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem):
self.config = config self.config = config
self.mqtt_config = config.mqtt self.mqtt_config = config.mqtt
self.state_manager = state_manager self.state_manager = state_manager
self.event_system = event_system self.event_system = event_system
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# MQTT client # MQTT client
self.client: Optional[mqtt.Client] = None self.client: Optional[mqtt.Client] = None
self.connected = False self.connected = False
self.running = False self.running = False
# Threading # Threading
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
# Message handler # Message handler
self.message_handler = MQTTMessageHandler(state_manager, event_system) self.message_handler = MQTTMessageHandler(state_manager, event_system)
# Connection retry settings # Connection retry settings
self.reconnect_delay = 5 # seconds self.reconnect_delay = 5 # seconds
self.max_reconnect_attempts = 10 self.max_reconnect_attempts = 10
# Topic mapping (topic -> machine_name) # Topic mapping (topic -> machine_name)
self.topic_to_machine = { self.topic_to_machine = {topic: machine_name for machine_name, topic in self.mqtt_config.topics.items()}
topic: machine_name
for machine_name, topic in self.mqtt_config.topics.items() # Status tracking
} self.start_time = None
self.message_count = 0
self.error_count = 0
self.last_message_time = None
def start(self) -> bool: def start(self) -> bool:
"""Start the MQTT client in a separate thread""" """Start the MQTT client in a separate thread"""
if self.running: if self.running:
self.logger.warning("MQTT client is already running") self.logger.warning("MQTT client is already running")
return True return True
self.logger.info("Starting MQTT client...") self.logger.info("Starting MQTT client...")
self.running = True self.running = True
self._stop_event.clear() self._stop_event.clear()
self.start_time = time.time()
# Start in separate thread # Start in separate thread
self._thread = threading.Thread(target=self._run_loop, daemon=True) self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start() self._thread.start()
# Wait a moment to see if connection succeeds # Wait a moment to see if connection succeeds
time.sleep(2) time.sleep(2)
return self.connected return self.connected
def stop(self) -> None: def stop(self) -> None:
"""Stop the MQTT client""" """Stop the MQTT client"""
if not self.running: if not self.running:
return return
self.logger.info("Stopping MQTT client...") self.logger.info("Stopping MQTT client...")
self.running = False self.running = False
self._stop_event.set() self._stop_event.set()
if self.client and self.connected: if self.client and self.connected:
self.client.disconnect() self.client.disconnect()
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
self._thread.join(timeout=5) self._thread.join(timeout=5)
self.logger.info("MQTT client stopped") self.logger.info("MQTT client stopped")
def _run_loop(self) -> None: def _run_loop(self) -> None:
"""Main MQTT client loop""" """Main MQTT client loop"""
reconnect_attempts = 0 reconnect_attempts = 0
while self.running and not self._stop_event.is_set(): while self.running and not self._stop_event.is_set():
try: try:
if not self.connected: if not self.connected:
if self._connect(): if self._connect():
reconnect_attempts = 0 reconnect_attempts = 0
self._subscribe_to_topics()
else: else:
reconnect_attempts += 1 reconnect_attempts += 1
if reconnect_attempts >= self.max_reconnect_attempts: if reconnect_attempts >= self.max_reconnect_attempts:
self.logger.error(f"Max reconnection attempts ({self.max_reconnect_attempts}) reached") self.logger.error(f"Max reconnection attempts ({self.max_reconnect_attempts}) reached")
break break
self.logger.warning(f"Reconnection attempt {reconnect_attempts}/{self.max_reconnect_attempts} in {self.reconnect_delay}s") self.logger.warning(f"Reconnection attempt {reconnect_attempts}/{self.max_reconnect_attempts} in {self.reconnect_delay}s")
if self._stop_event.wait(self.reconnect_delay): if self._stop_event.wait(self.reconnect_delay):
break break
continue continue
# Process MQTT messages # Process MQTT messages
if self.client: if self.client:
self.client.loop(timeout=1.0) self.client.loop(timeout=1.0)
# Small delay to prevent busy waiting # Small delay to prevent busy waiting
if self._stop_event.wait(0.1): if self._stop_event.wait(0.1):
break break
except Exception as e: except Exception as e:
self.logger.error(f"Error in MQTT loop: {e}") self.logger.error(f"Error in MQTT loop: {e}")
self.connected = False self.connected = False
if self._stop_event.wait(self.reconnect_delay): if self._stop_event.wait(self.reconnect_delay):
break break
self.running = False self.running = False
self.logger.info("MQTT client loop ended") self.logger.info("MQTT client loop ended")
def _connect(self) -> bool: def _connect(self) -> bool:
"""Connect to MQTT broker""" """Connect to MQTT broker"""
try: try:
# Create new client instance # Create new client instance
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
# Set callbacks # Set callbacks
self.client.on_connect = self._on_connect self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message self.client.on_message = self._on_message
# Set authentication if provided # Set authentication if provided
if self.mqtt_config.username and self.mqtt_config.password: if self.mqtt_config.username and self.mqtt_config.password:
self.client.username_pw_set( self.client.username_pw_set(self.mqtt_config.username, self.mqtt_config.password)
self.mqtt_config.username,
self.mqtt_config.password
)
# Connect to broker # Connect to broker
self.logger.info(f"Connecting to MQTT broker at {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}") self.logger.info(f"Connecting to MQTT broker at {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
self.client.connect( self.client.connect(self.mqtt_config.broker_host, self.mqtt_config.broker_port, 60)
self.mqtt_config.broker_host,
self.mqtt_config.broker_port,
60
)
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Failed to connect to MQTT broker: {e}") self.logger.error(f"Failed to connect to MQTT broker: {e}")
return False return False
def _subscribe_to_topics(self) -> None: def _subscribe_to_topics(self) -> None:
"""Subscribe to all configured topics""" """Subscribe to all configured topics"""
if not self.client or not self.connected: if not self.client or not self.connected:
return return
for machine_name, topic in self.mqtt_config.topics.items(): for machine_name, topic in self.mqtt_config.topics.items():
try: try:
result, mid = self.client.subscribe(topic) result, mid = self.client.subscribe(topic)
if result == mqtt.MQTT_ERR_SUCCESS: if result == mqtt.MQTT_ERR_SUCCESS:
self.logger.info(f"Subscribed to topic: {topic} (machine: {machine_name})") self.logger.info(f"📋 MQTT SUBSCRIBED: {topic} (machine: {machine_name})")
print(f"📋 MQTT SUBSCRIBED: {machine_name}{topic}")
else: else:
self.logger.error(f"Failed to subscribe to topic: {topic}") self.logger.error(f"❌ MQTT SUBSCRIPTION FAILED: {topic}")
print(f"❌ MQTT SUBSCRIPTION FAILED: {topic}")
except Exception as e: except Exception as e:
self.logger.error(f"Error subscribing to topic {topic}: {e}") self.logger.error(f"Error subscribing to topic {topic}: {e}")
def _on_connect(self, client, userdata, flags, rc) -> None: def _on_connect(self, client, userdata, flags, rc) -> None:
"""Callback for when the client connects to the broker""" """Callback for when the client connects to the broker"""
if rc == 0: if rc == 0:
self.connected = True self.connected = True
self.state_manager.set_mqtt_connected(True) self.state_manager.set_mqtt_connected(True)
self.event_system.publish(EventType.MQTT_CONNECTED, "mqtt_client") self.event_system.publish(EventType.MQTT_CONNECTED, "mqtt_client")
self.logger.info("Successfully connected to MQTT broker") self.logger.info("🔗 MQTT CONNECTED to broker successfully")
print(f"🔗 MQTT CONNECTED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
# Subscribe to topics immediately after connection
self._subscribe_to_topics()
else: else:
self.connected = False self.connected = False
self.logger.error(f"Failed to connect to MQTT broker, return code {rc}") self.logger.error(f"❌ MQTT CONNECTION FAILED with return code {rc}")
print(f"❌ MQTT CONNECTION FAILED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port} (code: {rc})")
def _on_disconnect(self, client, userdata, rc) -> None: def _on_disconnect(self, client, userdata, rc) -> None:
"""Callback for when the client disconnects from the broker""" """Callback for when the client disconnects from the broker"""
self.connected = False self.connected = False
self.state_manager.set_mqtt_connected(False) self.state_manager.set_mqtt_connected(False)
self.event_system.publish(EventType.MQTT_DISCONNECTED, "mqtt_client") self.event_system.publish(EventType.MQTT_DISCONNECTED, "mqtt_client")
if rc != 0: if rc != 0:
self.logger.warning(f"Unexpected MQTT disconnection (rc: {rc})") self.logger.warning(f"⚠️ MQTT DISCONNECTED unexpectedly (rc: {rc})")
print(f"⚠️ MQTT DISCONNECTED: Unexpected disconnection (code: {rc})")
else: else:
self.logger.info("MQTT client disconnected") self.logger.info("🔌 MQTT DISCONNECTED gracefully")
print("🔌 MQTT DISCONNECTED: Graceful disconnection")
def _on_message(self, client, userdata, msg) -> None: def _on_message(self, client, userdata, msg) -> None:
"""Callback for when a message is received""" """Callback for when a message is received"""
try: try:
topic = msg.topic topic = msg.topic
payload = msg.payload.decode('utf-8').strip() payload = msg.payload.decode("utf-8").strip()
self.logger.debug(f"MQTT message received - Topic: {topic}, Payload: {payload}") self.logger.debug(f"MQTT message received - Topic: {topic}, Payload: {payload}")
# Update MQTT activity # Update MQTT activity and tracking
self.state_manager.update_mqtt_activity() self.state_manager.update_mqtt_activity()
self.message_count += 1
self.last_message_time = time.time()
# Get machine name from topic # Get machine name from topic
machine_name = self.topic_to_machine.get(topic) machine_name = self.topic_to_machine.get(topic)
if not machine_name: if not machine_name:
self.logger.warning(f"Unknown topic: {topic}") self.logger.warning(f"❓ MQTT UNKNOWN TOPIC: {topic}")
print(f"❓ MQTT UNKNOWN TOPIC: {topic}")
return return
# Show MQTT message on console
print(f"📡 MQTT MESSAGE: {machine_name}{payload}")
# Handle the message # Handle the message
self.message_handler.handle_message(machine_name, topic, payload) self.message_handler.handle_message(machine_name, topic, payload)
except Exception as e: except Exception as e:
self.error_count += 1
self.logger.error(f"Error processing MQTT message: {e}") self.logger.error(f"Error processing MQTT message: {e}")
def publish_message(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool: def publish_message(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
"""Publish a message to MQTT broker""" """Publish a message to MQTT broker"""
if not self.client or not self.connected: if not self.client or not self.connected:
self.logger.warning("Cannot publish: MQTT client not connected") self.logger.warning("Cannot publish: MQTT client not connected")
return False return False
try: try:
result = self.client.publish(topic, payload, qos, retain) result = self.client.publish(topic, payload, qos, retain)
if result.rc == mqtt.MQTT_ERR_SUCCESS: if result.rc == mqtt.MQTT_ERR_SUCCESS:
@@ -230,22 +242,26 @@ class MQTTClient:
except Exception as e: except Exception as e:
self.logger.error(f"Error publishing message: {e}") self.logger.error(f"Error publishing message: {e}")
return False return False
def get_status(self) -> Dict[str, any]: def get_status(self) -> Dict[str, Any]:
"""Get MQTT client status""" """Get MQTT client status"""
return { uptime_seconds = None
"connected": self.connected, last_message_time_str = None
"running": self.running,
"broker_host": self.mqtt_config.broker_host, if self.start_time:
"broker_port": self.mqtt_config.broker_port, uptime_seconds = time.time() - self.start_time
"subscribed_topics": list(self.mqtt_config.topics.values()),
"topic_mappings": self.topic_to_machine if self.last_message_time:
} from datetime import datetime
last_message_time_str = datetime.fromtimestamp(self.last_message_time).isoformat()
return {"connected": self.connected, "running": self.running, "broker_host": self.mqtt_config.broker_host, "broker_port": self.mqtt_config.broker_port, "subscribed_topics": list(self.mqtt_config.topics.values()), "topic_mappings": self.topic_to_machine, "message_count": self.message_count, "error_count": self.error_count, "last_message_time": last_message_time_str, "uptime_seconds": uptime_seconds}
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Check if MQTT client is connected""" """Check if MQTT client is connected"""
return self.connected return self.connected
def is_running(self) -> bool: def is_running(self) -> bool:
"""Check if MQTT client is running""" """Check if MQTT client is running"""
return self.running return self.running

View File

@@ -14,69 +14,63 @@ from ..core.events import EventSystem, publish_machine_state_changed
class MQTTMessageHandler: class MQTTMessageHandler:
"""Handles MQTT messages and triggers appropriate system actions""" """Handles MQTT messages and triggers appropriate system actions"""
def __init__(self, state_manager: StateManager, event_system: EventSystem): def __init__(self, state_manager: StateManager, event_system: EventSystem):
self.state_manager = state_manager self.state_manager = state_manager
self.event_system = event_system self.event_system = event_system
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Message processing statistics # Message processing statistics
self.message_count = 0 self.message_count = 0
self.last_message_time: Optional[datetime] = None self.last_message_time: Optional[datetime] = None
self.error_count = 0 self.error_count = 0
def handle_message(self, machine_name: str, topic: str, payload: str) -> None: def handle_message(self, machine_name: str, topic: str, payload: str) -> None:
"""Handle an incoming MQTT message""" """Handle an incoming MQTT message"""
try: try:
self.message_count += 1 self.message_count += 1
self.last_message_time = datetime.now() self.last_message_time = datetime.now()
self.logger.info(f"Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: {payload}") self.logger.info(f"Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: {payload}")
# Normalize payload # Normalize payload
normalized_payload = self._normalize_payload(payload) normalized_payload = self._normalize_payload(payload)
# Update machine state # Update machine state
state_changed = self.state_manager.update_machine_state( state_changed = self.state_manager.update_machine_state(name=machine_name, state=normalized_payload, message=payload, topic=topic)
name=machine_name,
state=normalized_payload, # Store MQTT event in history
message=payload, self.state_manager.add_mqtt_event(machine_name=machine_name, topic=topic, payload=payload, normalized_state=normalized_payload)
topic=topic
)
# Publish state change event if state actually changed # Publish state change event if state actually changed
if state_changed: if state_changed:
publish_machine_state_changed( publish_machine_state_changed(machine_name=machine_name, state=normalized_payload, source="mqtt_handler")
machine_name=machine_name,
state=normalized_payload,
source="mqtt_handler"
)
self.logger.info(f"Machine {machine_name} state changed to: {normalized_payload}") self.logger.info(f"Machine {machine_name} state changed to: {normalized_payload}")
# Log the message for debugging # Log the message for debugging
self._log_message_details(machine_name, topic, payload, normalized_payload) self._log_message_details(machine_name, topic, payload, normalized_payload)
except Exception as e: except Exception as e:
self.error_count += 1 self.error_count += 1
self.logger.error(f"Error handling MQTT message for {machine_name}: {e}") self.logger.error(f"Error handling MQTT message for {machine_name}: {e}")
def _normalize_payload(self, payload: str) -> str: def _normalize_payload(self, payload: str) -> str:
"""Normalize payload to standard machine states""" """Normalize payload to standard machine states"""
payload_lower = payload.lower().strip() payload_lower = payload.lower().strip()
# Map various possible payloads to standard states # Map various possible payloads to standard states
if payload_lower in ['on', 'true', '1', 'start', 'running', 'active']: if payload_lower in ["on", "true", "1", "start", "running", "active"]:
return 'on' return "on"
elif payload_lower in ['off', 'false', '0', 'stop', 'stopped', 'inactive']: elif payload_lower in ["off", "false", "0", "stop", "stopped", "inactive"]:
return 'off' return "off"
elif payload_lower in ['error', 'fault', 'alarm']: elif payload_lower in ["error", "fault", "alarm"]:
return 'error' return "error"
else: else:
# For unknown payloads, log and return as-is # For unknown payloads, log and return as-is
self.logger.warning(f"Unknown payload format: '{payload}', treating as raw state") self.logger.warning(f"Unknown payload format: '{payload}', treating as raw state")
return payload_lower return payload_lower
def _log_message_details(self, machine_name: str, topic: str, original_payload: str, normalized_payload: str) -> None: def _log_message_details(self, machine_name: str, topic: str, original_payload: str, normalized_payload: str) -> None:
"""Log detailed message information""" """Log detailed message information"""
self.logger.debug(f"MQTT Message Details:") self.logger.debug(f"MQTT Message Details:")
@@ -86,16 +80,11 @@ class MQTTMessageHandler:
self.logger.debug(f" Normalized Payload: '{normalized_payload}'") self.logger.debug(f" Normalized Payload: '{normalized_payload}'")
self.logger.debug(f" Timestamp: {self.last_message_time}") self.logger.debug(f" Timestamp: {self.last_message_time}")
self.logger.debug(f" Total Messages Processed: {self.message_count}") self.logger.debug(f" Total Messages Processed: {self.message_count}")
def get_statistics(self) -> Dict[str, any]: def get_statistics(self) -> Dict[str, any]:
"""Get message processing statistics""" """Get message processing statistics"""
return { return {"total_messages": self.message_count, "error_count": self.error_count, "last_message_time": self.last_message_time.isoformat() if self.last_message_time else None, "success_rate": (self.message_count - self.error_count) / max(self.message_count, 1) * 100}
"total_messages": self.message_count,
"error_count": self.error_count,
"last_message_time": self.last_message_time.isoformat() if self.last_message_time else None,
"success_rate": (self.message_count - self.error_count) / max(self.message_count, 1) * 100
}
def reset_statistics(self) -> None: def reset_statistics(self) -> None:
"""Reset message processing statistics""" """Reset message processing statistics"""
self.message_count = 0 self.message_count = 0
@@ -106,47 +95,47 @@ class MQTTMessageHandler:
class MachineStateProcessor: class MachineStateProcessor:
"""Processes machine state changes and determines actions""" """Processes machine state changes and determines actions"""
def __init__(self, state_manager: StateManager, event_system: EventSystem): def __init__(self, state_manager: StateManager, event_system: EventSystem):
self.state_manager = state_manager self.state_manager = state_manager
self.event_system = event_system self.event_system = event_system
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
def process_state_change(self, machine_name: str, old_state: str, new_state: str) -> None: def process_state_change(self, machine_name: str, old_state: str, new_state: str) -> None:
"""Process a machine state change and determine what actions to take""" """Process a machine state change and determine what actions to take"""
self.logger.info(f"Processing state change for {machine_name}: {old_state} -> {new_state}") self.logger.info(f"Processing state change for {machine_name}: {old_state} -> {new_state}")
# Handle state transitions # Handle state transitions
if old_state != 'on' and new_state == 'on': if old_state != "on" and new_state == "on":
self._handle_machine_turned_on(machine_name) self._handle_machine_turned_on(machine_name)
elif old_state == 'on' and new_state != 'on': elif old_state == "on" and new_state != "on":
self._handle_machine_turned_off(machine_name) self._handle_machine_turned_off(machine_name)
elif new_state == 'error': elif new_state == "error":
self._handle_machine_error(machine_name) self._handle_machine_error(machine_name)
def _handle_machine_turned_on(self, machine_name: str) -> None: def _handle_machine_turned_on(self, machine_name: str) -> None:
"""Handle machine turning on - should start recording""" """Handle machine turning on - should start recording"""
self.logger.info(f"Machine {machine_name} turned ON - should start recording") self.logger.info(f"Machine {machine_name} turned ON - should start recording")
# The actual recording start will be handled by the camera manager # The actual recording start will be handled by the camera manager
# which listens to the MACHINE_STATE_CHANGED event # which listens to the MACHINE_STATE_CHANGED event
# We could add additional logic here, such as: # We could add additional logic here, such as:
# - Checking if camera is available # - Checking if camera is available
# - Pre-warming camera settings # - Pre-warming camera settings
# - Sending notifications # - Sending notifications
def _handle_machine_turned_off(self, machine_name: str) -> None: def _handle_machine_turned_off(self, machine_name: str) -> None:
"""Handle machine turning off - should stop recording""" """Handle machine turning off - should stop recording"""
self.logger.info(f"Machine {machine_name} turned OFF - should stop recording") self.logger.info(f"Machine {machine_name} turned OFF - should stop recording")
# The actual recording stop will be handled by the camera manager # The actual recording stop will be handled by the camera manager
# which listens to the MACHINE_STATE_CHANGED event # which listens to the MACHINE_STATE_CHANGED event
def _handle_machine_error(self, machine_name: str) -> None: def _handle_machine_error(self, machine_name: str) -> None:
"""Handle machine error state""" """Handle machine error state"""
self.logger.warning(f"Machine {machine_name} in ERROR state") self.logger.warning(f"Machine {machine_name} in ERROR state")
# Could implement error handling logic here: # Could implement error handling logic here:
# - Stop recording if active # - Stop recording if active
# - Send alerts # - Send alerts

View File

@@ -19,7 +19,7 @@ from ..core.events import EventSystem, EventType, Event
class StorageManager: class StorageManager:
"""Manages storage and file organization for recorded videos""" """Manages storage and file organization for recorded videos"""
def __init__(self, config: Config, state_manager: StateManager, event_system: Optional[EventSystem] = None): def __init__(self, config: Config, state_manager: StateManager, event_system: Optional[EventSystem] = None):
self.config = config self.config = config
self.storage_config = config.storage self.storage_config = config.storage
@@ -37,20 +37,20 @@ class StorageManager:
# Subscribe to recording events if event system is available # Subscribe to recording events if event system is available
if self.event_system: if self.event_system:
self._setup_event_subscriptions() self._setup_event_subscriptions()
def _ensure_storage_structure(self) -> None: def _ensure_storage_structure(self) -> None:
"""Ensure storage directory structure exists""" """Ensure storage directory structure exists"""
try: try:
# Create base storage directory # Create base storage directory
Path(self.storage_config.base_path).mkdir(parents=True, exist_ok=True) Path(self.storage_config.base_path).mkdir(parents=True, exist_ok=True)
# Create camera-specific directories # Create camera-specific directories
for camera_config in self.config.cameras: for camera_config in self.config.cameras:
Path(camera_config.storage_path).mkdir(parents=True, exist_ok=True) Path(camera_config.storage_path).mkdir(parents=True, exist_ok=True)
self.logger.debug(f"Ensured storage directory: {camera_config.storage_path}") self.logger.debug(f"Ensured storage directory: {camera_config.storage_path}")
self.logger.info("Storage directory structure verified") self.logger.info("Storage directory structure verified")
except Exception as e: except Exception as e:
self.logger.error(f"Error creating storage structure: {e}") self.logger.error(f"Error creating storage structure: {e}")
raise raise
@@ -66,12 +66,7 @@ class StorageManager:
camera_name = event.data.get("camera_name") camera_name = event.data.get("camera_name")
filename = event.data.get("filename") filename = event.data.get("filename")
if camera_name and filename: if camera_name and filename:
self.register_recording_file( self.register_recording_file(camera_name=camera_name, filename=filename, start_time=event.timestamp, machine_trigger=event.data.get("machine_trigger"))
camera_name=camera_name,
filename=filename,
start_time=event.timestamp,
machine_trigger=event.data.get("machine_trigger")
)
except Exception as e: except Exception as e:
self.logger.error(f"Error handling recording started event: {e}") self.logger.error(f"Error handling recording started event: {e}")
@@ -81,64 +76,48 @@ class StorageManager:
filename = event.data.get("filename") filename = event.data.get("filename")
if filename: if filename:
file_id = os.path.basename(filename) file_id = os.path.basename(filename)
self.finalize_recording_file( self.finalize_recording_file(file_id=file_id, end_time=event.timestamp, duration_seconds=event.data.get("duration_seconds"))
file_id=file_id,
end_time=event.timestamp,
duration_seconds=event.data.get("duration_seconds")
)
except Exception as e: except Exception as e:
self.logger.error(f"Error handling recording stopped event: {e}") self.logger.error(f"Error handling recording stopped event: {e}")
# Subscribe to recording events # Subscribe to recording events
self.event_system.subscribe(EventType.RECORDING_STARTED, on_recording_started) self.event_system.subscribe(EventType.RECORDING_STARTED, on_recording_started)
self.event_system.subscribe(EventType.RECORDING_STOPPED, on_recording_stopped) self.event_system.subscribe(EventType.RECORDING_STOPPED, on_recording_stopped)
def _load_file_index(self) -> Dict[str, Any]: def _load_file_index(self) -> Dict[str, Any]:
"""Load file index from disk""" """Load file index from disk"""
try: try:
if os.path.exists(self.file_index_path): if os.path.exists(self.file_index_path):
with open(self.file_index_path, 'r') as f: with open(self.file_index_path, "r") as f:
return json.load(f) return json.load(f)
else: else:
return {"files": {}, "last_updated": None} return {"files": {}, "last_updated": None}
except Exception as e: except Exception as e:
self.logger.error(f"Error loading file index: {e}") self.logger.error(f"Error loading file index: {e}")
return {"files": {}, "last_updated": None} return {"files": {}, "last_updated": None}
def _save_file_index(self) -> None: def _save_file_index(self) -> None:
"""Save file index to disk""" """Save file index to disk"""
try: try:
self.file_index["last_updated"] = datetime.now().isoformat() self.file_index["last_updated"] = datetime.now().isoformat()
with open(self.file_index_path, 'w') as f: with open(self.file_index_path, "w") as f:
json.dump(self.file_index, f, indent=2) json.dump(self.file_index, f, indent=2)
except Exception as e: except Exception as e:
self.logger.error(f"Error saving file index: {e}") self.logger.error(f"Error saving file index: {e}")
def register_recording_file(self, camera_name: str, filename: str, start_time: datetime, def register_recording_file(self, camera_name: str, filename: str, start_time: datetime, machine_trigger: Optional[str] = None) -> str:
machine_trigger: Optional[str] = None) -> str:
"""Register a new recording file""" """Register a new recording file"""
try: try:
file_id = os.path.basename(filename) file_id = os.path.basename(filename)
file_info = { file_info = {"camera_name": camera_name, "filename": filename, "file_id": file_id, "start_time": start_time.isoformat(), "end_time": None, "file_size_bytes": None, "duration_seconds": None, "machine_trigger": machine_trigger, "status": "recording", "created_at": datetime.now().isoformat()}
"camera_name": camera_name,
"filename": filename,
"file_id": file_id,
"start_time": start_time.isoformat(),
"end_time": None,
"file_size_bytes": None,
"duration_seconds": None,
"machine_trigger": machine_trigger,
"status": "recording",
"created_at": datetime.now().isoformat()
}
self.file_index["files"][file_id] = file_info self.file_index["files"][file_id] = file_info
self._save_file_index() self._save_file_index()
self.logger.info(f"Registered recording file: {file_id}") self.logger.info(f"Registered recording file: {file_id}")
return file_id return file_id
except Exception as e: except Exception as e:
self.logger.error(f"Error registering recording file: {e}") self.logger.error(f"Error registering recording file: {e}")
return "" return ""
@@ -169,52 +148,50 @@ class StorageManager:
except Exception as e: except Exception as e:
self.logger.error(f"Error finalizing recording file: {e}") self.logger.error(f"Error finalizing recording file: {e}")
return False return False
def finalize_recording_file(self, file_id: str, end_time: datetime, def finalize_recording_file(self, file_id: str, end_time: datetime, duration_seconds: float, frame_count: Optional[int] = None) -> bool:
duration_seconds: float, frame_count: Optional[int] = None) -> bool:
"""Finalize a recording file after recording stops""" """Finalize a recording file after recording stops"""
try: try:
if file_id not in self.file_index["files"]: if file_id not in self.file_index["files"]:
self.logger.warning(f"File ID not found in index: {file_id}") self.logger.warning(f"File ID not found in index: {file_id}")
return False return False
file_info = self.file_index["files"][file_id] file_info = self.file_index["files"][file_id]
filename = file_info["filename"] filename = file_info["filename"]
# Update file information # Update file information
file_info["end_time"] = end_time.isoformat() file_info["end_time"] = end_time.isoformat()
file_info["duration_seconds"] = duration_seconds file_info["duration_seconds"] = duration_seconds
file_info["status"] = "completed" file_info["status"] = "completed"
# Get file size if file exists # Get file size if file exists
if os.path.exists(filename): if os.path.exists(filename):
file_info["file_size_bytes"] = os.path.getsize(filename) file_info["file_size_bytes"] = os.path.getsize(filename)
if frame_count is not None: if frame_count is not None:
file_info["frame_count"] = frame_count file_info["frame_count"] = frame_count
self._save_file_index() self._save_file_index()
self.logger.info(f"Finalized recording file: {file_id} (duration: {duration_seconds:.1f}s)") self.logger.info(f"Finalized recording file: {file_id} (duration: {duration_seconds:.1f}s)")
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error finalizing recording file: {e}") self.logger.error(f"Error finalizing recording file: {e}")
return False return False
def get_recording_files(self, camera_name: Optional[str] = None, def get_recording_files(self, camera_name: Optional[str] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]:
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: Optional[int] = None) -> List[Dict[str, Any]]:
"""Get list of recording files with optional filters""" """Get list of recording files with optional filters"""
try: try:
files = [] files = []
# First, get files from the index (if available)
indexed_files = set()
for file_id, file_info in self.file_index["files"].items(): for file_id, file_info in self.file_index["files"].items():
# Filter by camera name # Filter by camera name
if camera_name and file_info["camera_name"] != camera_name: if camera_name and file_info["camera_name"] != camera_name:
continue continue
# Filter by date range # Filter by date range
if start_date or end_date: if start_date or end_date:
file_start = datetime.fromisoformat(file_info["start_time"]) file_start = datetime.fromisoformat(file_info["start_time"])
@@ -222,88 +199,106 @@ class StorageManager:
continue continue
if end_date and file_start > end_date: if end_date and file_start > end_date:
continue continue
files.append(file_info.copy()) files.append(file_info.copy())
indexed_files.add(file_info["filename"])
# Then, scan filesystem for files not in the index
for camera_config in self.config.cameras:
# Skip if filtering by camera name and this isn't the one
if camera_name and camera_config.name != camera_name:
continue
storage_path = Path(camera_config.storage_path)
if storage_path.exists():
for video_file in storage_path.glob("*.avi"):
if video_file.is_file() and str(video_file) not in indexed_files:
# Get file stats
stat = video_file.stat()
file_mtime = datetime.fromtimestamp(stat.st_mtime)
# Apply date filters
if start_date and file_mtime < start_date:
continue
if end_date and file_mtime > end_date:
continue
# Create file info for unindexed file
file_info = {"camera_name": camera_config.name, "filename": str(video_file), "file_id": video_file.name, "start_time": file_mtime.isoformat(), "end_time": None, "file_size_bytes": stat.st_size, "duration_seconds": None, "machine_trigger": None, "status": "unknown", "created_at": file_mtime.isoformat()} # We don't know if it's completed or not
files.append(file_info)
# Sort by start time (newest first) # Sort by start time (newest first)
files.sort(key=lambda x: x["start_time"], reverse=True) files.sort(key=lambda x: x["start_time"], reverse=True)
# Apply limit # Apply limit
if limit: if limit:
files = files[:limit] files = files[:limit]
return files return files
except Exception as e: except Exception as e:
self.logger.error(f"Error getting recording files: {e}") self.logger.error(f"Error getting recording files: {e}")
return [] return []
def get_storage_statistics(self) -> Dict[str, Any]: def get_storage_statistics(self) -> Dict[str, Any]:
"""Get storage usage statistics""" """Get storage usage statistics"""
try: try:
stats = { stats = {"base_path": self.storage_config.base_path, "total_files": 0, "total_size_bytes": 0, "cameras": {}, "disk_usage": {}}
"base_path": self.storage_config.base_path,
"total_files": 0,
"total_size_bytes": 0,
"cameras": {},
"disk_usage": {}
}
# Get disk usage for base path # Get disk usage for base path
if os.path.exists(self.storage_config.base_path): if os.path.exists(self.storage_config.base_path):
disk_usage = shutil.disk_usage(self.storage_config.base_path) disk_usage = shutil.disk_usage(self.storage_config.base_path)
stats["disk_usage"] = { stats["disk_usage"] = {"total_bytes": disk_usage.total, "used_bytes": disk_usage.used, "free_bytes": disk_usage.free, "used_percent": (disk_usage.used / disk_usage.total) * 100}
"total_bytes": disk_usage.total,
"used_bytes": disk_usage.used, # Scan actual filesystem for all video files
"free_bytes": disk_usage.free, # This ensures we count all files, not just those in the index
"used_percent": (disk_usage.used / disk_usage.total) * 100 for camera_config in self.config.cameras:
} camera_name = camera_config.name
storage_path = Path(camera_config.storage_path)
# Analyze files by camera
if camera_name not in stats["cameras"]:
stats["cameras"][camera_name] = {"file_count": 0, "total_size_bytes": 0, "total_duration_seconds": 0}
# Scan for video files in camera directory
if storage_path.exists():
for video_file in storage_path.glob("*.avi"):
if video_file.is_file():
stats["total_files"] += 1
stats["cameras"][camera_name]["file_count"] += 1
# Get file size
try:
file_size = video_file.stat().st_size
stats["total_size_bytes"] += file_size
stats["cameras"][camera_name]["total_size_bytes"] += file_size
except Exception as e:
self.logger.warning(f"Could not get size for {video_file}: {e}")
# Add duration information from index if available
for file_info in self.file_index["files"].values(): for file_info in self.file_index["files"].values():
camera_name = file_info["camera_name"] camera_name = file_info["camera_name"]
if camera_name in stats["cameras"] and file_info.get("duration_seconds"):
if camera_name not in stats["cameras"]:
stats["cameras"][camera_name] = {
"file_count": 0,
"total_size_bytes": 0,
"total_duration_seconds": 0
}
stats["total_files"] += 1
stats["cameras"][camera_name]["file_count"] += 1
if file_info.get("file_size_bytes"):
size = file_info["file_size_bytes"]
stats["total_size_bytes"] += size
stats["cameras"][camera_name]["total_size_bytes"] += size
if file_info.get("duration_seconds"):
duration = file_info["duration_seconds"] duration = file_info["duration_seconds"]
stats["cameras"][camera_name]["total_duration_seconds"] += duration stats["cameras"][camera_name]["total_duration_seconds"] += duration
return stats return stats
except Exception as e: except Exception as e:
self.logger.error(f"Error getting storage statistics: {e}") self.logger.error(f"Error getting storage statistics: {e}")
return {} return {}
def cleanup_old_files(self, max_age_days: Optional[int] = None) -> Dict[str, Any]: def cleanup_old_files(self, max_age_days: Optional[int] = None) -> Dict[str, Any]:
"""Clean up old recording files""" """Clean up old recording files"""
if max_age_days is None: if max_age_days is None:
max_age_days = self.storage_config.cleanup_older_than_days max_age_days = self.storage_config.cleanup_older_than_days
cutoff_date = datetime.now() - timedelta(days=max_age_days) cutoff_date = datetime.now() - timedelta(days=max_age_days)
cleanup_stats = { cleanup_stats = {"files_removed": 0, "bytes_freed": 0, "errors": []}
"files_removed": 0,
"bytes_freed": 0,
"errors": []
}
try: try:
files_to_remove = [] files_to_remove = []
# Find files older than cutoff date # Find files older than cutoff date
for file_id, file_info in self.file_index["files"].items(): for file_id, file_info in self.file_index["files"].items():
try: try:
@@ -312,81 +307,74 @@ class StorageManager:
files_to_remove.append((file_id, file_info)) files_to_remove.append((file_id, file_info))
except Exception as e: except Exception as e:
cleanup_stats["errors"].append(f"Error parsing date for {file_id}: {e}") cleanup_stats["errors"].append(f"Error parsing date for {file_id}: {e}")
# Remove old files # Remove old files
for file_id, file_info in files_to_remove: for file_id, file_info in files_to_remove:
try: try:
filename = file_info["filename"] filename = file_info["filename"]
# Remove physical file # Remove physical file
if os.path.exists(filename): if os.path.exists(filename):
file_size = os.path.getsize(filename) file_size = os.path.getsize(filename)
os.remove(filename) os.remove(filename)
cleanup_stats["bytes_freed"] += file_size cleanup_stats["bytes_freed"] += file_size
self.logger.info(f"Removed old file: {filename}") self.logger.info(f"Removed old file: {filename}")
# Remove from index # Remove from index
del self.file_index["files"][file_id] del self.file_index["files"][file_id]
cleanup_stats["files_removed"] += 1 cleanup_stats["files_removed"] += 1
except Exception as e: except Exception as e:
error_msg = f"Error removing file {file_id}: {e}" error_msg = f"Error removing file {file_id}: {e}"
cleanup_stats["errors"].append(error_msg) cleanup_stats["errors"].append(error_msg)
self.logger.error(error_msg) self.logger.error(error_msg)
# Save updated index # Save updated index
if cleanup_stats["files_removed"] > 0: if cleanup_stats["files_removed"] > 0:
self._save_file_index() self._save_file_index()
self.logger.info(f"Cleanup completed: {cleanup_stats['files_removed']} files removed, " self.logger.info(f"Cleanup completed: {cleanup_stats['files_removed']} files removed, " f"{cleanup_stats['bytes_freed']} bytes freed")
f"{cleanup_stats['bytes_freed']} bytes freed")
return cleanup_stats return cleanup_stats
except Exception as e: except Exception as e:
self.logger.error(f"Error during cleanup: {e}") self.logger.error(f"Error during cleanup: {e}")
cleanup_stats["errors"].append(str(e)) cleanup_stats["errors"].append(str(e))
return cleanup_stats return cleanup_stats
def get_file_info(self, file_id: str) -> Optional[Dict[str, Any]]: def get_file_info(self, file_id: str) -> Optional[Dict[str, Any]]:
"""Get information about a specific file""" """Get information about a specific file"""
return self.file_index["files"].get(file_id) return self.file_index["files"].get(file_id)
def delete_file(self, file_id: str) -> bool: def delete_file(self, file_id: str) -> bool:
"""Delete a specific recording file""" """Delete a specific recording file"""
try: try:
if file_id not in self.file_index["files"]: if file_id not in self.file_index["files"]:
self.logger.warning(f"File ID not found: {file_id}") self.logger.warning(f"File ID not found: {file_id}")
return False return False
file_info = self.file_index["files"][file_id] file_info = self.file_index["files"][file_id]
filename = file_info["filename"] filename = file_info["filename"]
# Remove physical file # Remove physical file
if os.path.exists(filename): if os.path.exists(filename):
os.remove(filename) os.remove(filename)
self.logger.info(f"Deleted file: {filename}") self.logger.info(f"Deleted file: {filename}")
# Remove from index # Remove from index
del self.file_index["files"][file_id] del self.file_index["files"][file_id]
self._save_file_index() self._save_file_index()
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error deleting file {file_id}: {e}") self.logger.error(f"Error deleting file {file_id}: {e}")
return False return False
def verify_storage_integrity(self) -> Dict[str, Any]: def verify_storage_integrity(self) -> Dict[str, Any]:
"""Verify storage integrity and fix issues""" """Verify storage integrity and fix issues"""
integrity_report = { integrity_report = {"total_files_in_index": len(self.file_index["files"]), "missing_files": [], "orphaned_files": [], "corrupted_entries": [], "fixed_issues": 0}
"total_files_in_index": len(self.file_index["files"]),
"missing_files": [],
"orphaned_files": [],
"corrupted_entries": [],
"fixed_issues": 0
}
try: try:
# Check for missing files (in index but not on disk) # Check for missing files (in index but not on disk)
for file_id, file_info in list(self.file_index["files"].items()): for file_id, file_info in list(self.file_index["files"].items()):
@@ -396,7 +384,7 @@ class StorageManager:
# Remove from index # Remove from index
del self.file_index["files"][file_id] del self.file_index["files"][file_id]
integrity_report["fixed_issues"] += 1 integrity_report["fixed_issues"] += 1
# Check for orphaned files (on disk but not in index) # Check for orphaned files (on disk but not in index)
for camera_config in self.config.cameras: for camera_config in self.config.cameras:
storage_path = Path(camera_config.storage_path) storage_path = Path(camera_config.storage_path)
@@ -405,15 +393,15 @@ class StorageManager:
file_id = video_file.name file_id = video_file.name
if file_id not in self.file_index["files"]: if file_id not in self.file_index["files"]:
integrity_report["orphaned_files"].append(str(video_file)) integrity_report["orphaned_files"].append(str(video_file))
# Save updated index if fixes were made # Save updated index if fixes were made
if integrity_report["fixed_issues"] > 0: if integrity_report["fixed_issues"] > 0:
self._save_file_index() self._save_file_index()
self.logger.info(f"Storage integrity check completed: {integrity_report['fixed_issues']} issues fixed") self.logger.info(f"Storage integrity check completed: {integrity_report['fixed_issues']} issues fixed")
return integrity_report return integrity_report
except Exception as e: except Exception as e:
self.logger.error(f"Error during integrity check: {e}") self.logger.error(f"Error during integrity check: {e}")
integrity_report["error"] = str(e) integrity_report["error"] = str(e)

442
uv.lock generated
View File

@@ -33,6 +33,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
] ]
[[package]]
name = "appnope"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" },
]
[[package]]
name = "asttokens"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.7.14" version = "2025.7.14"
@@ -42,6 +60,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" }, { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
] ]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" },
{ url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" },
{ url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" },
{ url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" },
{ url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" },
{ url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" },
{ url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" },
{ url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" },
{ url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" },
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" },
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" },
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" },
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" },
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" },
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.2" version = "3.4.2"
@@ -111,6 +174,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "comm"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
]
[[package]] [[package]]
name = "contourpy" name = "contourpy"
version = "1.3.2" version = "1.3.2"
@@ -174,6 +246,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
] ]
[[package]]
name = "debugpy"
version = "1.8.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/3a9a28ddb750a76eaec445c7f4d3147ea2c579a97dbd9e25d39001b92b21/debugpy-1.8.15.tar.gz", hash = "sha256:58d7a20b7773ab5ee6bdfb2e6cf622fdf1e40c9d5aef2857d85391526719ac00", size = 1643279, upload-time = "2025-07-15T16:43:29.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/b3/1c44a2ed311199ab11c2299c9474a6c7cd80d19278defd333aeb7c287995/debugpy-1.8.15-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:babc4fb1962dd6a37e94d611280e3d0d11a1f5e6c72ac9b3d87a08212c4b6dd3", size = 2183442, upload-time = "2025-07-15T16:43:36.733Z" },
{ url = "https://files.pythonhosted.org/packages/f6/69/e2dcb721491e1c294d348681227c9b44fb95218f379aa88e12a19d85528d/debugpy-1.8.15-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f778e68f2986a58479d0ac4f643e0b8c82fdd97c2e200d4d61e7c2d13838eb53", size = 3134215, upload-time = "2025-07-15T16:43:38.116Z" },
{ url = "https://files.pythonhosted.org/packages/17/76/4ce63b95d8294dcf2fd1820860b300a420d077df4e93afcaa25a984c2ca7/debugpy-1.8.15-cp311-cp311-win32.whl", hash = "sha256:f9d1b5abd75cd965e2deabb1a06b0e93a1546f31f9f621d2705e78104377c702", size = 5154037, upload-time = "2025-07-15T16:43:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a7/e5a7c784465eb9c976d84408873d597dc7ce74a0fc69ed009548a1a94813/debugpy-1.8.15-cp311-cp311-win_amd64.whl", hash = "sha256:62954fb904bec463e2b5a415777f6d1926c97febb08ef1694da0e5d1463c5c3b", size = 5178133, upload-time = "2025-07-15T16:43:40.969Z" },
{ url = "https://files.pythonhosted.org/packages/ab/4a/4508d256e52897f5cdfee6a6d7580974811e911c6d01321df3264508a5ac/debugpy-1.8.15-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:3dcc7225cb317469721ab5136cda9ff9c8b6e6fb43e87c9e15d5b108b99d01ba", size = 2511197, upload-time = "2025-07-15T16:43:42.343Z" },
{ url = "https://files.pythonhosted.org/packages/99/8d/7f6ef1097e7fecf26b4ef72338d08e41644a41b7ee958a19f494ffcffc29/debugpy-1.8.15-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:047a493ca93c85ccede1dbbaf4e66816794bdc214213dde41a9a61e42d27f8fc", size = 4229517, upload-time = "2025-07-15T16:43:44.14Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e8/e8c6a9aa33a9c9c6dacbf31747384f6ed2adde4de2e9693c766bdf323aa3/debugpy-1.8.15-cp312-cp312-win32.whl", hash = "sha256:b08e9b0bc260cf324c890626961dad4ffd973f7568fbf57feb3c3a65ab6b6327", size = 5276132, upload-time = "2025-07-15T16:43:45.529Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ad/231050c6177b3476b85fcea01e565dac83607b5233d003ff067e2ee44d8f/debugpy-1.8.15-cp312-cp312-win_amd64.whl", hash = "sha256:e2a4fe357c92334272eb2845fcfcdbec3ef9f22c16cf613c388ac0887aed15fa", size = 5317645, upload-time = "2025-07-15T16:43:46.968Z" },
{ url = "https://files.pythonhosted.org/packages/28/70/2928aad2310726d5920b18ed9f54b9f06df5aa4c10cf9b45fa18ff0ab7e8/debugpy-1.8.15-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:f5e01291ad7d6649aed5773256c5bba7a1a556196300232de1474c3c372592bf", size = 2495538, upload-time = "2025-07-15T16:43:48.927Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c6/9b8ffb4ca91fac8b2877eef63c9cc0e87dd2570b1120054c272815ec4cd0/debugpy-1.8.15-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94dc0f0d00e528d915e0ce1c78e771475b2335b376c49afcc7382ee0b146bab6", size = 4221874, upload-time = "2025-07-15T16:43:50.282Z" },
{ url = "https://files.pythonhosted.org/packages/55/8a/9b8d59674b4bf489318c7c46a1aab58e606e583651438084b7e029bf3c43/debugpy-1.8.15-cp313-cp313-win32.whl", hash = "sha256:fcf0748d4f6e25f89dc5e013d1129ca6f26ad4da405e0723a4f704583896a709", size = 5275949, upload-time = "2025-07-15T16:43:52.079Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/9e58e6fdfa8710a5e6ec06c2401241b9ad48b71c0a7eb99570a1f1edb1d3/debugpy-1.8.15-cp313-cp313-win_amd64.whl", hash = "sha256:73c943776cb83e36baf95e8f7f8da765896fd94b05991e7bc162456d25500683", size = 5317720, upload-time = "2025-07-15T16:43:53.703Z" },
{ url = "https://files.pythonhosted.org/packages/07/d5/98748d9860e767a1248b5e31ffa7ce8cb7006e97bf8abbf3d891d0a8ba4e/debugpy-1.8.15-py2.py3-none-any.whl", hash = "sha256:bce2e6c5ff4f2e00b98d45e7e01a49c7b489ff6df5f12d881c67d2f1ac635f3d", size = 5282697, upload-time = "2025-07-15T16:44:07.996Z" },
]
[[package]]
name = "decorator"
version = "5.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
[[package]]
name = "executing"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.116.1" version = "0.116.1"
@@ -252,6 +363,106 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl", hash = "sha256:11efa15b87bc7871b61590326b2d635439acc321cf7f8ce996f812543ce10eed", size = 315796, upload-time = "2025-01-20T02:42:34.931Z" }, { url = "https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl", hash = "sha256:11efa15b87bc7871b61590326b2d635439acc321cf7f8ce996f812543ce10eed", size = 315796, upload-time = "2025-01-20T02:42:34.931Z" },
] ]
[[package]]
name = "ipykernel"
version = "6.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "appnope", marker = "sys_platform == 'darwin'" },
{ name = "comm" },
{ name = "debugpy" },
{ name = "ipython" },
{ name = "jupyter-client" },
{ name = "jupyter-core" },
{ name = "matplotlib-inline" },
{ name = "nest-asyncio" },
{ name = "packaging" },
{ name = "psutil" },
{ name = "pyzmq" },
{ name = "tornado" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/27/9e6e30ed92f2ac53d29f70b09da8b2dc456e256148e289678fa0e825f46a/ipykernel-6.30.0.tar.gz", hash = "sha256:b7b808ddb2d261aae2df3a26ff3ff810046e6de3dfbc6f7de8c98ea0a6cb632c", size = 165125, upload-time = "2025-07-21T10:36:09.259Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/3d/00813c3d9b46e3dcd88bd4530e0a3c63c0509e5d8c9eff34723ea243ab04/ipykernel-6.30.0-py3-none-any.whl", hash = "sha256:fd2936e55c4a1c2ee8b1e5fa6a372b8eecc0ab1338750dee76f48fa5cca1301e", size = 117264, upload-time = "2025-07-21T10:36:06.854Z" },
]
[[package]]
name = "ipython"
version = "9.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "decorator" },
{ name = "ipython-pygments-lexers" },
{ name = "jedi" },
{ name = "matplotlib-inline" },
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "prompt-toolkit" },
{ name = "pygments" },
{ name = "stack-data" },
{ name = "traitlets" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/54/80/406f9e3bde1c1fd9bf5a0be9d090f8ae623e401b7670d8f6fdf2ab679891/ipython-9.4.0.tar.gz", hash = "sha256:c033c6d4e7914c3d9768aabe76bbe87ba1dc66a92a05db6bfa1125d81f2ee270", size = 4385338, upload-time = "2025-07-01T11:11:30.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/f8/0031ee2b906a15a33d6bfc12dd09c3dfa966b3cb5b284ecfb7549e6ac3c4/ipython-9.4.0-py3-none-any.whl", hash = "sha256:25850f025a446d9b359e8d296ba175a36aedd32e83ca9b5060430fe16801f066", size = 611021, upload-time = "2025-07-01T11:11:27.85Z" },
]
[[package]]
name = "ipython-pygments-lexers"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
]
[[package]]
name = "jedi"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "parso" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
]
[[package]]
name = "jupyter-client"
version = "8.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jupyter-core" },
{ name = "python-dateutil" },
{ name = "pyzmq" },
{ name = "tornado" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" },
]
[[package]]
name = "jupyter-core"
version = "5.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
{ name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923, upload-time = "2025-05-27T07:38:16.655Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/57/6bffd4b20b88da3800c5d691e0337761576ee688eb01299eae865689d2df/jupyter_core-5.8.1-py3-none-any.whl", hash = "sha256:c28d268fc90fb53f1338ded2eb410704c5449a358406e8a948b75706e24863d0", size = 28880, upload-time = "2025-05-27T07:38:15.137Z" },
]
[[package]] [[package]]
name = "kiwisolver" name = "kiwisolver"
version = "1.4.8" version = "1.4.8"
@@ -361,6 +572,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044, upload-time = "2025-05-08T19:10:44.551Z" }, { url = "https://files.pythonhosted.org/packages/1b/92/9a45c91089c3cf690b5badd4be81e392ff086ccca8a1d4e3a08463d8a966/matplotlib-3.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4f23ffe95c5667ef8a2b56eea9b53db7f43910fa4a2d5472ae0f72b64deab4d5", size = 8139044, upload-time = "2025-05-08T19:10:44.551Z" },
] ]
[[package]]
name = "matplotlib-inline"
version = "0.1.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" },
]
[[package]]
name = "nest-asyncio"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
]
[[package]] [[package]]
name = "numpy" name = "numpy"
version = "2.3.2" version = "2.3.2"
@@ -477,6 +709,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
] ]
[[package]]
name = "parso"
version = "0.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" },
]
[[package]]
name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ptyprocess" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "11.3.0" version = "11.3.0"
@@ -561,6 +814,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" },
] ]
[[package]]
name = "platformdirs"
version = "4.3.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.51"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" },
]
[[package]]
name = "psutil"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" },
{ url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" },
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
]
[[package]]
name = "pure-eval"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
]
[[package]]
name = "pycparser"
version = "2.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.11.7" version = "2.11.7"
@@ -641,6 +957,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
] ]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.2.3" version = "3.2.3"
@@ -671,6 +996,70 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
] ]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" },
{ url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" },
{ url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" },
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pyzmq"
version = "27.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "implementation_name == 'pypy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/06/50a4e9648b3e8b992bef8eb632e457307553a89d294103213cfd47b3da69/pyzmq-27.0.0.tar.gz", hash = "sha256:b1f08eeb9ce1510e6939b6e5dcd46a17765e2333daae78ecf4606808442e52cf", size = 280478, upload-time = "2025-06-13T14:09:07.087Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/df/84c630654106d9bd9339cdb564aa941ed41b023a0264251d6743766bb50e/pyzmq-27.0.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:21457825249b2a53834fa969c69713f8b5a79583689387a5e7aed880963ac564", size = 1332718, upload-time = "2025-06-13T14:07:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8e/f6a5461a07654d9840d256476434ae0ff08340bba562a455f231969772cb/pyzmq-27.0.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1958947983fef513e6e98eff9cb487b60bf14f588dc0e6bf35fa13751d2c8251", size = 908248, upload-time = "2025-06-13T14:07:18.033Z" },
{ url = "https://files.pythonhosted.org/packages/7c/93/82863e8d695a9a3ae424b63662733ae204a295a2627d52af2f62c2cd8af9/pyzmq-27.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0dc628b5493f9a8cd9844b8bee9732ef587ab00002157c9329e4fc0ef4d3afa", size = 668647, upload-time = "2025-06-13T14:07:19.378Z" },
{ url = "https://files.pythonhosted.org/packages/f3/85/15278769b348121eacdbfcbd8c4d40f1102f32fa6af5be1ffc032ed684be/pyzmq-27.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7bbe9e1ed2c8d3da736a15694d87c12493e54cc9dc9790796f0321794bbc91f", size = 856600, upload-time = "2025-06-13T14:07:20.906Z" },
{ url = "https://files.pythonhosted.org/packages/d4/af/1c469b3d479bd095edb28e27f12eee10b8f00b356acbefa6aeb14dd295d1/pyzmq-27.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc1091f59143b471d19eb64f54bae4f54bcf2a466ffb66fe45d94d8d734eb495", size = 1657748, upload-time = "2025-06-13T14:07:22.549Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f4/17f965d0ee6380b1d6326da842a50e4b8b9699745161207945f3745e8cb5/pyzmq-27.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7011ade88c8e535cf140f8d1a59428676fbbce7c6e54fefce58bf117aefb6667", size = 2034311, upload-time = "2025-06-13T14:07:23.966Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6e/7c391d81fa3149fd759de45d298003de6cfab343fb03e92c099821c448db/pyzmq-27.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c386339d7e3f064213aede5d03d054b237937fbca6dd2197ac8cf3b25a6b14e", size = 1893630, upload-time = "2025-06-13T14:07:25.899Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e0/eaffe7a86f60e556399e224229e7769b717f72fec0706b70ab2c03aa04cb/pyzmq-27.0.0-cp311-cp311-win32.whl", hash = "sha256:0546a720c1f407b2172cb04b6b094a78773491497e3644863cf5c96c42df8cff", size = 567706, upload-time = "2025-06-13T14:07:27.595Z" },
{ url = "https://files.pythonhosted.org/packages/c9/05/89354a8cffdcce6e547d48adaaf7be17007fc75572123ff4ca90a4ca04fc/pyzmq-27.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:15f39d50bd6c9091c67315ceb878a4f531957b121d2a05ebd077eb35ddc5efed", size = 630322, upload-time = "2025-06-13T14:07:28.938Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/4ab976d5e1e63976719389cc4f3bfd248a7f5f2bb2ebe727542363c61b5f/pyzmq-27.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c5817641eebb391a2268c27fecd4162448e03538387093cdbd8bf3510c316b38", size = 558435, upload-time = "2025-06-13T14:07:30.256Z" },
{ url = "https://files.pythonhosted.org/packages/93/a7/9ad68f55b8834ede477842214feba6a4c786d936c022a67625497aacf61d/pyzmq-27.0.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:cbabc59dcfaac66655c040dfcb8118f133fb5dde185e5fc152628354c1598e52", size = 1305438, upload-time = "2025-06-13T14:07:31.676Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ee/26aa0f98665a22bc90ebe12dced1de5f3eaca05363b717f6fb229b3421b3/pyzmq-27.0.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:cb0ac5179cba4b2f94f1aa208fbb77b62c4c9bf24dd446278b8b602cf85fcda3", size = 895095, upload-time = "2025-06-13T14:07:33.104Z" },
{ url = "https://files.pythonhosted.org/packages/cf/85/c57e7ab216ecd8aa4cc7e3b83b06cc4e9cf45c87b0afc095f10cd5ce87c1/pyzmq-27.0.0-cp312-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53a48f0228eab6cbf69fde3aa3c03cbe04e50e623ef92ae395fce47ef8a76152", size = 651826, upload-time = "2025-06-13T14:07:34.831Z" },
{ url = "https://files.pythonhosted.org/packages/69/9a/9ea7e230feda9400fb0ae0d61d7d6ddda635e718d941c44eeab22a179d34/pyzmq-27.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:111db5f395e09f7e775f759d598f43cb815fc58e0147623c4816486e1a39dc22", size = 839750, upload-time = "2025-06-13T14:07:36.553Z" },
{ url = "https://files.pythonhosted.org/packages/08/66/4cebfbe71f3dfbd417011daca267539f62ed0fbc68105357b68bbb1a25b7/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c8878011653dcdc27cc2c57e04ff96f0471e797f5c19ac3d7813a245bcb24371", size = 1641357, upload-time = "2025-06-13T14:07:38.21Z" },
{ url = "https://files.pythonhosted.org/packages/ac/f6/b0f62578c08d2471c791287149cb8c2aaea414ae98c6e995c7dbe008adfb/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:c0ed2c1f335ba55b5fdc964622254917d6b782311c50e138863eda409fbb3b6d", size = 2020281, upload-time = "2025-06-13T14:07:39.599Z" },
{ url = "https://files.pythonhosted.org/packages/37/b9/4f670b15c7498495da9159edc374ec09c88a86d9cd5a47d892f69df23450/pyzmq-27.0.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e918d70862d4cfd4b1c187310015646a14e1f5917922ab45b29f28f345eeb6be", size = 1877110, upload-time = "2025-06-13T14:07:41.027Z" },
{ url = "https://files.pythonhosted.org/packages/66/31/9dee25c226295b740609f0d46db2fe972b23b6f5cf786360980524a3ba92/pyzmq-27.0.0-cp312-abi3-win32.whl", hash = "sha256:88b4e43cab04c3c0f0d55df3b1eef62df2b629a1a369b5289a58f6fa8b07c4f4", size = 559297, upload-time = "2025-06-13T14:07:42.533Z" },
{ url = "https://files.pythonhosted.org/packages/9b/12/52da5509800f7ff2d287b2f2b4e636e7ea0f001181cba6964ff6c1537778/pyzmq-27.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:dce4199bf5f648a902ce37e7b3afa286f305cd2ef7a8b6ec907470ccb6c8b371", size = 619203, upload-time = "2025-06-13T14:07:43.843Z" },
{ url = "https://files.pythonhosted.org/packages/93/6d/7f2e53b19d1edb1eb4f09ec7c3a1f945ca0aac272099eab757d15699202b/pyzmq-27.0.0-cp312-abi3-win_arm64.whl", hash = "sha256:56e46bbb85d52c1072b3f809cc1ce77251d560bc036d3a312b96db1afe76db2e", size = 551927, upload-time = "2025-06-13T14:07:45.51Z" },
{ url = "https://files.pythonhosted.org/packages/19/62/876b27c4ff777db4ceba1c69ea90d3c825bb4f8d5e7cd987ce5802e33c55/pyzmq-27.0.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:c36ad534c0c29b4afa088dc53543c525b23c0797e01b69fef59b1a9c0e38b688", size = 1340826, upload-time = "2025-06-13T14:07:46.881Z" },
{ url = "https://files.pythonhosted.org/packages/43/69/58ef8f4f59d3bcd505260c73bee87b008850f45edca40ddaba54273c35f4/pyzmq-27.0.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:67855c14173aec36395d7777aaba3cc527b393821f30143fd20b98e1ff31fd38", size = 897283, upload-time = "2025-06-13T14:07:49.562Z" },
{ url = "https://files.pythonhosted.org/packages/43/15/93a0d0396700a60475ad3c5d42c5f1c308d3570bc94626b86c71ef9953e0/pyzmq-27.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8617c7d43cd8ccdb62aebe984bfed77ca8f036e6c3e46dd3dddda64b10f0ab7a", size = 660567, upload-time = "2025-06-13T14:07:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b3/fe055513e498ca32f64509abae19b9c9eb4d7c829e02bd8997dd51b029eb/pyzmq-27.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:67bfbcbd0a04c575e8103a6061d03e393d9f80ffdb9beb3189261e9e9bc5d5e9", size = 847681, upload-time = "2025-06-13T14:07:52.77Z" },
{ url = "https://files.pythonhosted.org/packages/b6/4f/ff15300b00b5b602191f3df06bbc8dd4164e805fdd65bb77ffbb9c5facdc/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5cd11d46d7b7e5958121b3eaf4cd8638eff3a720ec527692132f05a57f14341d", size = 1650148, upload-time = "2025-06-13T14:07:54.178Z" },
{ url = "https://files.pythonhosted.org/packages/c4/6f/84bdfff2a224a6f26a24249a342e5906993c50b0761e311e81b39aef52a7/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b801c2e40c5aa6072c2f4876de8dccd100af6d9918d4d0d7aa54a1d982fd4f44", size = 2023768, upload-time = "2025-06-13T14:07:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/64/39/dc2db178c26a42228c5ac94a9cc595030458aa64c8d796a7727947afbf55/pyzmq-27.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20d5cb29e8c5f76a127c75b6e7a77e846bc4b655c373baa098c26a61b7ecd0ef", size = 1885199, upload-time = "2025-06-13T14:07:57.166Z" },
{ url = "https://files.pythonhosted.org/packages/c7/21/dae7b06a1f8cdee5d8e7a63d99c5d129c401acc40410bef2cbf42025e26f/pyzmq-27.0.0-cp313-cp313t-win32.whl", hash = "sha256:a20528da85c7ac7a19b7384e8c3f8fa707841fd85afc4ed56eda59d93e3d98ad", size = 575439, upload-time = "2025-06-13T14:07:58.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/bc/1709dc55f0970cf4cb8259e435e6773f9946f41a045c2cb90e870b7072da/pyzmq-27.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d8229f2efece6a660ee211d74d91dbc2a76b95544d46c74c615e491900dc107f", size = 639933, upload-time = "2025-06-13T14:08:00.777Z" },
{ url = "https://files.pythonhosted.org/packages/98/a6/92394373b8dbc1edc9d53c951e8d3989d518185174ee54492ec27711779d/pyzmq-27.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd1dc59763effd1576f8368047c9c31468fce0af89d76b5067641137506792ae", size = 835948, upload-time = "2025-06-13T14:08:43.516Z" },
{ url = "https://files.pythonhosted.org/packages/56/f3/4dc38d75d9995bfc18773df3e41f2a2ca9b740b06f1a15dbf404077e7588/pyzmq-27.0.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:60e8cc82d968174650c1860d7b716366caab9973787a1c060cf8043130f7d0f7", size = 799874, upload-time = "2025-06-13T14:08:45.017Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ba/64af397e0f421453dc68e31d5e0784d554bf39013a2de0872056e96e58af/pyzmq-27.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14fe7aaac86e4e93ea779a821967360c781d7ac5115b3f1a171ced77065a0174", size = 567400, upload-time = "2025-06-13T14:08:46.855Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/ec956cbe98809270b59a22891d5758edae147a258e658bf3024a8254c855/pyzmq-27.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ad0562d4e6abb785be3e4dd68599c41be821b521da38c402bc9ab2a8e7ebc7e", size = 747031, upload-time = "2025-06-13T14:08:48.419Z" },
{ url = "https://files.pythonhosted.org/packages/be/8a/4a3764a68abc02e2fbb0668d225b6fda5cd39586dd099cee8b2ed6ab0452/pyzmq-27.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9df43a2459cd3a3563404c1456b2c4c69564daa7dbaf15724c09821a3329ce46", size = 544726, upload-time = "2025-06-13T14:08:49.903Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.4" version = "2.32.4"
@@ -704,6 +1093,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
] ]
[[package]]
name = "stack-data"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asttokens" },
{ name = "executing" },
{ name = "pure-eval" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.47.2" version = "0.47.2"
@@ -717,6 +1120,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
] ]
[[package]]
name = "tornado"
version = "6.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/89/c72771c81d25d53fe33e3dca61c233b665b2780f21820ba6fd2c6793c12b/tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c", size = 509934, upload-time = "2025-05-22T18:15:38.788Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/89/f4532dee6843c9e0ebc4e28d4be04c67f54f60813e4bf73d595fe7567452/tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7", size = 441948, upload-time = "2025-05-22T18:15:20.862Z" },
{ url = "https://files.pythonhosted.org/packages/15/9a/557406b62cffa395d18772e0cdcf03bed2fff03b374677348eef9f6a3792/tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6", size = 440112, upload-time = "2025-05-22T18:15:22.591Z" },
{ url = "https://files.pythonhosted.org/packages/55/82/7721b7319013a3cf881f4dffa4f60ceff07b31b394e459984e7a36dc99ec/tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888", size = 443672, upload-time = "2025-05-22T18:15:24.027Z" },
{ url = "https://files.pythonhosted.org/packages/7d/42/d11c4376e7d101171b94e03cef0cbce43e823ed6567ceda571f54cf6e3ce/tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331", size = 443019, upload-time = "2025-05-22T18:15:25.735Z" },
{ url = "https://files.pythonhosted.org/packages/7d/f7/0c48ba992d875521ac761e6e04b0a1750f8150ae42ea26df1852d6a98942/tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e", size = 443252, upload-time = "2025-05-22T18:15:27.499Z" },
{ url = "https://files.pythonhosted.org/packages/89/46/d8d7413d11987e316df4ad42e16023cd62666a3c0dfa1518ffa30b8df06c/tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401", size = 443930, upload-time = "2025-05-22T18:15:29.299Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/f8049221c96a06df89bed68260e8ca94beca5ea532ffc63b1175ad31f9cc/tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692", size = 443351, upload-time = "2025-05-22T18:15:31.038Z" },
{ url = "https://files.pythonhosted.org/packages/76/ff/6a0079e65b326cc222a54720a748e04a4db246870c4da54ece4577bfa702/tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a", size = 443328, upload-time = "2025-05-22T18:15:32.426Z" },
{ url = "https://files.pythonhosted.org/packages/49/18/e3f902a1d21f14035b5bc6246a8c0f51e0eef562ace3a2cea403c1fb7021/tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365", size = 444396, upload-time = "2025-05-22T18:15:34.205Z" },
{ url = "https://files.pythonhosted.org/packages/7b/09/6526e32bf1049ee7de3bebba81572673b19a2a8541f795d887e92af1a8bc/tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b", size = 444840, upload-time = "2025-05-22T18:15:36.1Z" },
{ url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596, upload-time = "2025-05-22T18:15:37.433Z" },
]
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.1" version = "4.67.1"
@@ -729,6 +1151,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
] ]
[[package]]
name = "traitlets"
version = "5.14.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.1" version = "4.14.1"
@@ -766,6 +1197,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "imageio" }, { name = "imageio" },
{ name = "ipykernel" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "numpy" }, { name = "numpy" },
{ name = "opencv-python" }, { name = "opencv-python" },
@@ -782,6 +1214,7 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.104.0" }, { name = "fastapi", specifier = ">=0.104.0" },
{ name = "imageio", specifier = ">=2.37.0" }, { name = "imageio", specifier = ">=2.37.0" },
{ name = "ipykernel", specifier = ">=6.30.0" },
{ name = "matplotlib", specifier = ">=3.10.3" }, { name = "matplotlib", specifier = ">=3.10.3" },
{ name = "numpy", specifier = ">=2.3.2" }, { name = "numpy", specifier = ">=2.3.2" },
{ name = "opencv-python", specifier = ">=4.11.0.86" }, { name = "opencv-python", specifier = ">=4.11.0.86" },
@@ -807,6 +1240,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
] ]
[[package]]
name = "wcwidth"
version = "0.2.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" },
]
[[package]] [[package]]
name = "websockets" name = "websockets"
version = "15.0.1" version = "15.0.1"