diff --git a/.gitignore b/.gitignore index fa1fd26..2303924 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ Camera/log/* # Python cache */__pycache__/* +old tests/Camera/log/* +old tests/Camera/Data/* diff --git a/API_CHANGES_SUMMARY.md b/API_CHANGES_SUMMARY.md new file mode 100644 index 0000000..6da4518 --- /dev/null +++ b/API_CHANGES_SUMMARY.md @@ -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 diff --git a/CAMERA_RECOVERY_GUIDE.md b/CAMERA_RECOVERY_GUIDE.md new file mode 100644 index 0000000..963f3ef --- /dev/null +++ b/CAMERA_RECOVERY_GUIDE.md @@ -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. diff --git a/MQTT_LOGGING_GUIDE.md b/MQTT_LOGGING_GUIDE.md new file mode 100644 index 0000000..abe1859 --- /dev/null +++ b/MQTT_LOGGING_GUIDE.md @@ -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!** diff --git a/api-endpoints.http b/api-endpoints.http new file mode 100644 index 0000000..0476502 --- /dev/null +++ b/api-endpoints.http @@ -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 diff --git a/api-tests.http b/api-tests.http index 23c58e1..f447e90 100644 --- a/api-tests.http +++ b/api-tests.http @@ -8,33 +8,151 @@ GET http://localhost:8000/cameras/camera1/status ### -### Get camera2 status +### Get 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 Content-Type: application/json { - "camera_name": "camera1", - "filename": "manual_test_cam1.avi" + "filename": "manual22_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 Content-Type: application/json { - "camera_name": "camera2", "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 POST http://localhost:8000/cameras/camera1/stop-recording @@ -43,6 +161,8 @@ POST http://localhost:8000/cameras/camera1/stop-recording ### Stop camera2 recording POST http://localhost:8000/cameras/camera2/stop-recording +### +### SYSTEM STATUS AND STORAGE TESTS ### ### Get all cameras status @@ -77,4 +197,112 @@ Content-Type: application/json ### ### Health check -GET http://localhost:8000/health \ No newline at end of file +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 \ No newline at end of file diff --git a/config.json b/config.json index ce985ea..63b3f97 100644 --- a/config.json +++ b/config.json @@ -17,7 +17,7 @@ }, "system": { "camera_check_interval_seconds": 2, - "log_level": "INFO", + "log_level": "DEBUG", "log_file": "usda_vision_system.log", "api_host": "0.0.0.0", "api_port": 8000, @@ -32,7 +32,20 @@ "exposure_ms": 1.0, "gain": 3.5, "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", @@ -41,7 +54,20 @@ "exposure_ms": 1.0, "gain": 3.5, "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 } ] } \ No newline at end of file diff --git a/demo_mqtt_console.py b/demo_mqtt_console.py new file mode 100644 index 0000000..b31670d --- /dev/null +++ b/demo_mqtt_console.py @@ -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() diff --git a/mqtt_publisher_test.py b/mqtt_publisher_test.py new file mode 100644 index 0000000..a9b3ac6 --- /dev/null +++ b/mqtt_publisher_test.py @@ -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() diff --git a/mqtt_test.py b/mqtt_test.py new file mode 100644 index 0000000..2e50796 --- /dev/null +++ b/mqtt_test.py @@ -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() diff --git a/old tests/camera_status_test.ipynb b/old tests/camera_status_test.ipynb index eba562f..26662fa 100644 --- a/old tests/camera_status_test.ipynb +++ b/old tests/camera_status_test.ipynb @@ -18,7 +18,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 26, "id": "imports", "metadata": {}, "outputs": [ @@ -42,7 +42,7 @@ "from datetime import datetime\n", "\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", "\n", "print(\"Libraries imported successfully!\")\n", @@ -51,7 +51,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 27, "id": "error-codes", "metadata": {}, "outputs": [ @@ -88,7 +88,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 28, "id": "status-functions", "metadata": {}, "outputs": [ @@ -215,7 +215,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 29, "id": "test-capture-availability", "metadata": {}, "outputs": [ @@ -375,7 +375,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 30, "id": "comprehensive-check", "metadata": {}, "outputs": [ @@ -408,7 +408,7 @@ "FINAL RESULTS:\n", "Camera Available: False\n", "Capture Ready: False\n", - "Status: (6, 'AVAILABLE')\n", + "Status: (42, 'AVAILABLE')\n", "==================================================\n" ] } @@ -455,7 +455,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 31, "id": "status-check-function", "metadata": {}, "outputs": [ diff --git a/old tests/camera_test_setup.ipynb b/old tests/camera_test_setup.ipynb index 08ecbab..8c91de7 100644 --- a/old tests/camera_test_setup.ipynb +++ b/old tests/camera_test_setup.ipynb @@ -18,9 +18,19 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "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": [ "import cv2\n", "import numpy as np\n", @@ -50,9 +60,17 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "โœ… Utility functions loaded!\n" + ] + } + ], "source": [ "def display_image(image, title=\"Image\", figsize=(10, 8)):\n", " \"\"\"Display image inline in Jupyter notebook\"\"\"\n", @@ -130,9 +148,22 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "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": [ "# Check storage directory\n", "storage_path = Path(\"/storage\")\n", @@ -155,9 +186,92 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "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": [ "# Scan for cameras\n", "cameras = list_available_cameras()\n", @@ -173,9 +287,41 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "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": [ "# Test a specific camera (change camera_id as needed)\n", "camera_id = 0 # Change this to test different cameras\n", @@ -327,9 +473,9 @@ ], "metadata": { "kernelspec": { - "display_name": "usda-vision-cameras", + "display_name": "USDA-vision-cameras", "language": "python", - "name": "usda-vision-cameras" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -341,7 +487,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.0" + "version": "3.11.2" } }, "nbformat": 4, diff --git a/pyproject.toml b/pyproject.toml index 5c41266..36b0d11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,4 +17,5 @@ dependencies = [ "websockets>=12.0", "requests>=2.31.0", "pytz>=2023.3", + "ipykernel>=6.30.0", ] diff --git a/test_api_changes.py b/test_api_changes.py new file mode 100644 index 0000000..a5cff99 --- /dev/null +++ b/test_api_changes.py @@ -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) diff --git a/test_camera_recovery_api.py b/test_camera_recovery_api.py new file mode 100644 index 0000000..25bb6c5 --- /dev/null +++ b/test_camera_recovery_api.py @@ -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() diff --git a/test_max_fps.py b/test_max_fps.py new file mode 100644 index 0000000..f3180c4 --- /dev/null +++ b/test_max_fps.py @@ -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() diff --git a/test_mqtt_events_api.py b/test_mqtt_events_api.py new file mode 100644 index 0000000..90cc60a --- /dev/null +++ b/test_mqtt_events_api.py @@ -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() diff --git a/test_mqtt_logging.py b/test_mqtt_logging.py new file mode 100644 index 0000000..23499e9 --- /dev/null +++ b/test_mqtt_logging.py @@ -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() diff --git a/test_system.py b/test_system.py index 5cdcf92..5c7deb1 100644 --- a/test_system.py +++ b/test_system.py @@ -15,6 +15,7 @@ from datetime import datetime # Add the current directory to Python path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + def test_imports(): """Test that all modules can be imported""" print("Testing imports...") @@ -27,46 +28,49 @@ def test_imports(): from usda_vision_system.storage.manager import StorageManager from usda_vision_system.api.server import APIServer from usda_vision_system.main import USDAVisionSystem + print("โœ… All imports successful") return True except Exception as e: print(f"โŒ Import failed: {e}") return False + def test_configuration(): """Test configuration loading""" print("\nTesting configuration...") try: from usda_vision_system.core.config import Config - + # Test default config config = Config() print(f"โœ… Default config loaded") print(f" MQTT broker: {config.mqtt.broker_host}:{config.mqtt.broker_port}") print(f" Storage path: {config.storage.base_path}") print(f" Cameras configured: {len(config.cameras)}") - + # Test config file if it exists if os.path.exists("config.json"): config_file = Config("config.json") print(f"โœ… Config file loaded") - + return True except Exception as e: print(f"โŒ Configuration test failed: {e}") return False + def test_camera_discovery(): """Test camera discovery""" print("\nTesting camera discovery...") try: - sys.path.append('./python demo') + sys.path.append("./python demo") import mvsdk - + devices = mvsdk.CameraEnumerateDevice() print(f"โœ… Camera discovery successful") print(f" Found {len(devices)} camera(s)") - + for i, device in enumerate(devices): try: name = device.GetFriendlyName() @@ -74,13 +78,14 @@ def test_camera_discovery(): print(f" Camera {i}: {name} ({port_type})") except Exception as e: print(f" Camera {i}: Error getting info - {e}") - + return True except Exception as e: print(f"โŒ Camera discovery failed: {e}") print(" Make sure GigE cameras are connected and python demo library is available") return False + def test_storage_setup(): """Test storage directory 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.storage.manager import StorageManager from usda_vision_system.core.state_manager import StateManager - + config = Config() state_manager = StateManager() storage_manager = StorageManager(config, state_manager) - + # Test storage statistics stats = storage_manager.get_storage_statistics() print(f"โœ… Storage manager initialized") print(f" Base path: {stats.get('base_path', 'Unknown')}") print(f" Total files: {stats.get('total_files', 0)}") - + return True except Exception as e: print(f"โŒ Storage setup failed: {e}") return False + def test_mqtt_config(): """Test MQTT configuration (without connecting)""" print("\nTesting MQTT configuration...") @@ -112,45 +118,47 @@ def test_mqtt_config(): from usda_vision_system.mqtt.client import MQTTClient from usda_vision_system.core.state_manager import StateManager from usda_vision_system.core.events import EventSystem - + config = Config() state_manager = StateManager() event_system = EventSystem() - + mqtt_client = MQTTClient(config, state_manager, event_system) status = mqtt_client.get_status() - + print(f"โœ… MQTT client initialized") print(f" Broker: {status['broker_host']}:{status['broker_port']}") print(f" Topics: {len(status['subscribed_topics'])}") - for topic in status['subscribed_topics']: + for topic in status["subscribed_topics"]: print(f" - {topic}") - + return True except Exception as e: print(f"โŒ MQTT configuration test failed: {e}") return False + def test_system_initialization(): """Test full system initialization (without starting)""" print("\nTesting system initialization...") try: from usda_vision_system.main import USDAVisionSystem - + # Create system instance system = USDAVisionSystem() - + # Check system status status = system.get_system_status() print(f"โœ… System initialized successfully") print(f" Running: {status['running']}") print(f" Components initialized: {len(status['components'])}") - + return True except Exception as e: print(f"โŒ System initialization failed: {e}") return False + def test_api_endpoints(): """Test API endpoints if server is running""" print("\nTesting API endpoints...") @@ -159,7 +167,7 @@ def test_api_endpoints(): response = requests.get("http://localhost:8000/health", timeout=5) if response.status_code == 200: print("โœ… API server is running") - + # Test system status endpoint try: 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}") except Exception as e: print(f"โš ๏ธ System status test failed: {e}") - + return True else: print(f"โš ๏ธ API server returned status {response.status_code}") @@ -184,34 +192,27 @@ def test_api_endpoints(): print(f"โŒ API test failed: {e}") return False + def main(): """Run all tests""" print("USDA Vision Camera System - Test Suite") print("=" * 50) - - tests = [ - test_imports, - test_configuration, - test_camera_discovery, - test_storage_setup, - test_mqtt_config, - test_system_initialization, - test_api_endpoints - ] - + + tests = [test_imports, test_configuration, test_camera_discovery, test_storage_setup, test_mqtt_config, test_system_initialization, test_api_endpoints] + passed = 0 total = len(tests) - + for test in tests: try: if test(): passed += 1 except Exception as e: print(f"โŒ Test {test.__name__} crashed: {e}") - + print("\n" + "=" * 50) print(f"Test Results: {passed}/{total} tests passed") - + if passed == total: print("๐ŸŽ‰ All tests passed! System appears to be working correctly.") return 0 @@ -219,5 +220,6 @@ def main(): print("โš ๏ธ Some tests failed. Check the output above for details.") return 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/usda_vision_system/api/__pycache__/models.cpython-311.pyc b/usda_vision_system/api/__pycache__/models.cpython-311.pyc index 9939a95..99a7af9 100644 Binary files a/usda_vision_system/api/__pycache__/models.cpython-311.pyc and b/usda_vision_system/api/__pycache__/models.cpython-311.pyc differ diff --git a/usda_vision_system/api/__pycache__/server.cpython-311.pyc b/usda_vision_system/api/__pycache__/server.cpython-311.pyc index 814eeed..9b983de 100644 Binary files a/usda_vision_system/api/__pycache__/server.cpython-311.pyc and b/usda_vision_system/api/__pycache__/server.cpython-311.pyc differ diff --git a/usda_vision_system/api/models.py b/usda_vision_system/api/models.py index ed03d97..02906b4 100644 --- a/usda_vision_system/api/models.py +++ b/usda_vision_system/api/models.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field class SystemStatusResponse(BaseModel): """System status response model""" + system_started: bool mqtt_connected: bool last_mqtt_message: Optional[str] = None @@ -23,6 +24,7 @@ class SystemStatusResponse(BaseModel): class MachineStatusResponse(BaseModel): """Machine status response model""" + name: str state: str last_updated: str @@ -30,8 +32,22 @@ class MachineStatusResponse(BaseModel): 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): """Camera status response model""" + name: str status: str is_recording: bool @@ -44,6 +60,7 @@ class CameraStatusResponse(BaseModel): class RecordingInfoResponse(BaseModel): """Recording information response model""" + camera_name: str filename: str start_time: str @@ -57,12 +74,16 @@ class RecordingInfoResponse(BaseModel): class StartRecordingRequest(BaseModel): """Start recording request model""" - camera_name: str + 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): """Start recording response model""" + success: bool message: str filename: Optional[str] = None @@ -70,11 +91,15 @@ class StartRecordingResponse(BaseModel): class StopRecordingRequest(BaseModel): """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): """Stop recording response model""" + success: bool message: str duration_seconds: Optional[float] = None @@ -82,6 +107,7 @@ class StopRecordingResponse(BaseModel): class StorageStatsResponse(BaseModel): """Storage statistics response model""" + base_path: str total_files: int total_size_bytes: int @@ -91,6 +117,7 @@ class StorageStatsResponse(BaseModel): class FileListRequest(BaseModel): """File list request model""" + camera_name: Optional[str] = None start_date: Optional[str] = None end_date: Optional[str] = None @@ -99,17 +126,20 @@ class FileListRequest(BaseModel): class FileListResponse(BaseModel): """File list response model""" + files: List[Dict[str, Any]] total_count: int class CleanupRequest(BaseModel): """Cleanup request model""" + max_age_days: Optional[int] = None class CleanupResponse(BaseModel): """Cleanup response model""" + files_removed: int bytes_freed: int errors: List[str] @@ -117,6 +147,7 @@ class CleanupResponse(BaseModel): class EventResponse(BaseModel): """Event response model""" + event_type: str source: str data: Dict[str, Any] @@ -125,6 +156,7 @@ class EventResponse(BaseModel): class WebSocketMessage(BaseModel): """WebSocket message model""" + type: str data: Dict[str, Any] timestamp: Optional[str] = None @@ -132,13 +164,53 @@ class WebSocketMessage(BaseModel): class ErrorResponse(BaseModel): """Error response model""" + error: str details: Optional[str] = None 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): """Success response model""" + success: bool = True message: str data: Optional[Dict[str, Any]] = None diff --git a/usda_vision_system/api/server.py b/usda_vision_system/api/server.py index 75c6a9f..028a596 100644 --- a/usda_vision_system/api/server.py +++ b/usda_vision_system/api/server.py @@ -25,31 +25,31 @@ from .models import * class WebSocketManager: """Manages WebSocket connections for real-time updates""" - + def __init__(self): self.active_connections: List[WebSocket] = [] self.logger = logging.getLogger(f"{__name__}.WebSocketManager") - + async def connect(self, websocket: WebSocket): await websocket.accept() self.active_connections.append(websocket) self.logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}") - + def disconnect(self, websocket: WebSocket): if websocket in self.active_connections: self.active_connections.remove(websocket) self.logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}") - + async def send_personal_message(self, message: dict, websocket: WebSocket): try: await websocket.send_text(json.dumps(message)) except Exception as e: self.logger.error(f"Error sending personal message: {e}") - + async def broadcast(self, message: dict): if not self.active_connections: return - + disconnected = [] for connection in self.active_connections: try: @@ -57,7 +57,7 @@ class WebSocketManager: except Exception as e: self.logger.error(f"Error broadcasting to connection: {e}") disconnected.append(connection) - + # Remove disconnected connections for connection in disconnected: self.disconnect(connection) @@ -65,9 +65,8 @@ class WebSocketManager: class APIServer: """FastAPI server for the USDA Vision Camera System""" - - def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, - camera_manager, mqtt_client, storage_manager: StorageManager): + + def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager, mqtt_client, storage_manager: StorageManager): self.config = config self.state_manager = state_manager self.event_system = event_system @@ -75,111 +74,101 @@ class APIServer: self.mqtt_client = mqtt_client self.storage_manager = storage_manager self.logger = logging.getLogger(__name__) - + # FastAPI app - self.app = FastAPI( - title="USDA Vision Camera System API", - description="API for monitoring and controlling the USDA vision camera system", - version="1.0.0" - ) - + self.app = FastAPI(title="USDA Vision Camera System API", description="API for monitoring and controlling the USDA vision camera system", version="1.0.0") + # WebSocket manager self.websocket_manager = WebSocketManager() - + # Server state self.server_start_time = datetime.now() self.running = False self._server_thread: Optional[threading.Thread] = None self._event_loop: Optional[asyncio.AbstractEventLoop] = None - + # Setup CORS - self.app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Configure appropriately for production - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - + self.app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) # Configure appropriately for production + # Setup routes self._setup_routes() - + # Subscribe to events for WebSocket broadcasting self._setup_event_subscriptions() - + def _setup_routes(self): """Setup API routes""" - + @self.app.get("/", response_model=SuccessResponse) async def root(): return SuccessResponse(message="USDA Vision Camera System API") - + @self.app.get("/health") async def health_check(): return {"status": "healthy", "timestamp": datetime.now().isoformat()} - + @self.app.get("/system/status", response_model=SystemStatusResponse) async def get_system_status(): """Get overall system status""" try: summary = self.state_manager.get_system_summary() uptime = (datetime.now() - self.server_start_time).total_seconds() - - return SystemStatusResponse( - system_started=summary["system_started"], - mqtt_connected=summary["mqtt_connected"], - last_mqtt_message=summary["last_mqtt_message"], - machines=summary["machines"], - cameras=summary["cameras"], - active_recordings=summary["active_recordings"], - total_recordings=summary["total_recordings"], - uptime_seconds=uptime - ) + + return SystemStatusResponse(system_started=summary["system_started"], mqtt_connected=summary["mqtt_connected"], last_mqtt_message=summary["last_mqtt_message"], machines=summary["machines"], cameras=summary["cameras"], active_recordings=summary["active_recordings"], total_recordings=summary["total_recordings"], uptime_seconds=uptime) except Exception as e: self.logger.error(f"Error getting system status: {e}") raise HTTPException(status_code=500, detail=str(e)) - + @self.app.get("/machines", response_model=Dict[str, MachineStatusResponse]) async def get_machines(): """Get all machine statuses""" try: machines = self.state_manager.get_all_machines() - return { - name: MachineStatusResponse( - name=machine.name, - state=machine.state.value, - last_updated=machine.last_updated.isoformat(), - last_message=machine.last_message, - mqtt_topic=machine.mqtt_topic - ) - for name, machine in machines.items() - } + return {name: MachineStatusResponse(name=machine.name, state=machine.state.value, last_updated=machine.last_updated.isoformat(), last_message=machine.last_message, mqtt_topic=machine.mqtt_topic) for name, machine in machines.items()} except Exception as e: self.logger.error(f"Error getting machines: {e}") raise HTTPException(status_code=500, detail=str(e)) - + + @self.app.get("/mqtt/status", response_model=MQTTStatusResponse) + async def get_mqtt_status(): + """Get MQTT client status and statistics""" + try: + status = self.mqtt_client.get_status() + return MQTTStatusResponse(connected=status["connected"], broker_host=status["broker_host"], broker_port=status["broker_port"], subscribed_topics=status["subscribed_topics"], last_message_time=status["last_message_time"], message_count=status["message_count"], error_count=status["error_count"], uptime_seconds=status["uptime_seconds"]) + except Exception as e: + self.logger.error(f"Error getting MQTT status: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @self.app.get("/mqtt/events", response_model=MQTTEventsHistoryResponse) + async def get_mqtt_events(limit: int = Query(default=5, ge=1, le=50, description="Number of recent events to retrieve")): + """Get recent MQTT events history""" + try: + events = self.state_manager.get_recent_mqtt_events(limit) + total_events = self.state_manager.get_mqtt_event_count() + + # Convert events to response format + event_responses = [MQTTEventResponse(machine_name=event.machine_name, topic=event.topic, payload=event.payload, normalized_state=event.normalized_state, timestamp=event.timestamp.isoformat(), message_number=event.message_number) for event in events] + + last_updated = events[0].timestamp.isoformat() if events else None + + return MQTTEventsHistoryResponse(events=event_responses, total_events=total_events, last_updated=last_updated) + except Exception as e: + self.logger.error(f"Error getting MQTT events: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @self.app.get("/cameras", response_model=Dict[str, CameraStatusResponse]) async def get_cameras(): """Get all camera statuses""" try: cameras = self.state_manager.get_all_cameras() return { - name: CameraStatusResponse( - name=camera.name, - status=camera.status.value, - is_recording=camera.is_recording, - last_checked=camera.last_checked.isoformat(), - last_error=camera.last_error, - device_info=camera.device_info, - current_recording_file=camera.current_recording_file, - recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None - ) + 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) for name, camera in cameras.items() } except Exception as e: self.logger.error(f"Error getting cameras: {e}") raise HTTPException(status_code=500, detail=str(e)) - + @self.app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse) async def get_camera_status(camera_name: str): """Get specific camera status""" @@ -187,70 +176,158 @@ class APIServer: camera = self.state_manager.get_camera_status(camera_name) if not camera: raise HTTPException(status_code=404, detail=f"Camera not found: {camera_name}") - - return CameraStatusResponse( - name=camera.name, - status=camera.status.value, - is_recording=camera.is_recording, - last_checked=camera.last_checked.isoformat(), - last_error=camera.last_error, - device_info=camera.device_info, - current_recording_file=camera.current_recording_file, - recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None - ) + + return CameraStatusResponse(name=camera.name, status=camera.status.value, is_recording=camera.is_recording, last_checked=camera.last_checked.isoformat(), last_error=camera.last_error, device_info=camera.device_info, current_recording_file=camera.current_recording_file, recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None) except HTTPException: raise except Exception as e: self.logger.error(f"Error getting camera status: {e}") raise HTTPException(status_code=500, detail=str(e)) - + @self.app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse) async def start_recording(camera_name: str, request: StartRecordingRequest): """Manually start recording for a camera""" try: if not self.camera_manager: raise HTTPException(status_code=503, detail="Camera manager not available") - - success = self.camera_manager.manual_start_recording(camera_name, 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: - return StartRecordingResponse( - success=True, - message=f"Recording started for {camera_name}", - filename=request.filename - ) + # Get the actual filename that was used (with datetime prefix) + actual_filename = request.filename + if request.filename: + from ..core.timezone_utils import format_filename_timestamp + + timestamp = format_filename_timestamp() + actual_filename = f"{timestamp}_{request.filename}" + + return StartRecordingResponse(success=True, message=f"Recording started for {camera_name}", filename=actual_filename) else: - return StartRecordingResponse( - success=False, - message=f"Failed to start recording for {camera_name}" - ) + return StartRecordingResponse(success=False, message=f"Failed to start recording for {camera_name}") except Exception as e: self.logger.error(f"Error starting recording: {e}") raise HTTPException(status_code=500, detail=str(e)) - + @self.app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse) async def stop_recording(camera_name: str): """Manually stop recording for a camera""" try: if not self.camera_manager: raise HTTPException(status_code=503, detail="Camera manager not available") - + success = self.camera_manager.manual_stop_recording(camera_name) - + if success: - return StopRecordingResponse( - success=True, - message=f"Recording stopped for {camera_name}" - ) + return StopRecordingResponse(success=True, message=f"Recording stopped for {camera_name}") else: - return StopRecordingResponse( - success=False, - message=f"Failed to stop recording for {camera_name}" - ) + return StopRecordingResponse(success=False, message=f"Failed to stop recording for {camera_name}") except Exception as e: self.logger.error(f"Error stopping recording: {e}") raise HTTPException(status_code=500, detail=str(e)) - + + @self.app.post("/cameras/{camera_name}/test-connection", response_model=CameraTestResponse) + async def test_camera_connection(camera_name: str): + """Test camera connection""" + try: + if not self.camera_manager: + raise HTTPException(status_code=503, detail="Camera manager not available") + + success = self.camera_manager.test_camera_connection(camera_name) + + if success: + return CameraTestResponse(success=True, message=f"Camera {camera_name} connection test passed", camera_name=camera_name) + else: + return CameraTestResponse(success=False, message=f"Camera {camera_name} connection test failed", camera_name=camera_name) + except Exception as e: + self.logger.error(f"Error testing camera connection: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @self.app.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]) async def get_recordings(): """Get all recording sessions""" @@ -266,14 +343,14 @@ class APIServer: file_size_bytes=recording.file_size_bytes, frame_count=recording.frame_count, duration_seconds=(recording.end_time - recording.start_time).total_seconds() if recording.end_time else None, - error_message=recording.error_message + error_message=recording.error_message, ) for rid, recording in recordings.items() } except Exception as e: self.logger.error(f"Error getting recordings: {e}") raise HTTPException(status_code=500, detail=str(e)) - + @self.app.get("/storage/stats", response_model=StorageStatsResponse) async def get_storage_stats(): """Get storage statistics""" @@ -283,34 +360,26 @@ class APIServer: except Exception as e: self.logger.error(f"Error getting storage stats: {e}") raise HTTPException(status_code=500, detail=str(e)) - + @self.app.post("/storage/files", response_model=FileListResponse) async def get_files(request: FileListRequest): """Get list of recording files""" try: start_date = None end_date = None - + if request.start_date: start_date = datetime.fromisoformat(request.start_date) if request.end_date: end_date = datetime.fromisoformat(request.end_date) - - files = self.storage_manager.get_recording_files( - camera_name=request.camera_name, - start_date=start_date, - end_date=end_date, - limit=request.limit - ) - - return FileListResponse( - files=files, - total_count=len(files) - ) + + files = self.storage_manager.get_recording_files(camera_name=request.camera_name, start_date=start_date, end_date=end_date, limit=request.limit) + + return FileListResponse(files=files, total_count=len(files)) except Exception as e: self.logger.error(f"Error getting files: {e}") raise HTTPException(status_code=500, detail=str(e)) - + @self.app.post("/storage/cleanup", response_model=CleanupResponse) async def cleanup_storage(request: CleanupRequest): """Clean up old storage files""" @@ -320,7 +389,7 @@ class APIServer: except Exception as e: self.logger.error(f"Error during cleanup: {e}") raise HTTPException(status_code=500, detail=str(e)) - + @self.app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): """WebSocket endpoint for real-time updates""" @@ -330,9 +399,7 @@ class APIServer: # Keep connection alive and handle incoming messages data = await websocket.receive_text() # Echo back for now - could implement commands later - await self.websocket_manager.send_personal_message( - {"type": "echo", "data": data}, websocket - ) + await self.websocket_manager.send_personal_message({"type": "echo", "data": data}, websocket) except WebSocketDisconnect: self.websocket_manager.disconnect(websocket) @@ -342,21 +409,12 @@ class APIServer: def broadcast_event(event: Event): """Broadcast event to all WebSocket connections""" try: - message = { - "type": "event", - "event_type": event.event_type.value, - "source": event.source, - "data": event.data, - "timestamp": event.timestamp.isoformat() - } + message = {"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 if self._event_loop and not self._event_loop.is_closed(): # Use call_soon_threadsafe to schedule the coroutine from another thread - asyncio.run_coroutine_threadsafe( - self.websocket_manager.broadcast(message), - self._event_loop - ) + asyncio.run_coroutine_threadsafe(self.websocket_manager.broadcast(message), self._event_loop) else: self.logger.debug("Event loop not available for broadcasting") @@ -411,12 +469,7 @@ class APIServer: self._event_loop = asyncio.new_event_loop() asyncio.set_event_loop(self._event_loop) - uvicorn.run( - self.app, - host=self.config.system.api_host, - port=self.config.system.api_port, - log_level="info" - ) + uvicorn.run(self.app, host=self.config.system.api_host, port=self.config.system.api_port, log_level="info") except Exception as e: self.logger.error(f"Error running API server: {e}") finally: @@ -429,11 +482,4 @@ class APIServer: def get_server_info(self) -> Dict[str, Any]: """Get server information""" - 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) - } + 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)} diff --git a/usda_vision_system/camera/__pycache__/manager.cpython-311.pyc b/usda_vision_system/camera/__pycache__/manager.cpython-311.pyc index cdef056..b7f9101 100644 Binary files a/usda_vision_system/camera/__pycache__/manager.cpython-311.pyc and b/usda_vision_system/camera/__pycache__/manager.cpython-311.pyc differ diff --git a/usda_vision_system/camera/__pycache__/monitor.cpython-311.pyc b/usda_vision_system/camera/__pycache__/monitor.cpython-311.pyc index 66df481..38e71e8 100644 Binary files a/usda_vision_system/camera/__pycache__/monitor.cpython-311.pyc and b/usda_vision_system/camera/__pycache__/monitor.cpython-311.pyc differ diff --git a/usda_vision_system/camera/__pycache__/recorder.cpython-311.pyc b/usda_vision_system/camera/__pycache__/recorder.cpython-311.pyc index 6b42b89..c657943 100644 Binary files a/usda_vision_system/camera/__pycache__/recorder.cpython-311.pyc and b/usda_vision_system/camera/__pycache__/recorder.cpython-311.pyc differ diff --git a/usda_vision_system/camera/manager.py b/usda_vision_system/camera/manager.py index dd5a899..56dd589 100644 --- a/usda_vision_system/camera/manager.py +++ b/usda_vision_system/camera/manager.py @@ -13,7 +13,7 @@ from typing import Dict, List, Optional, Tuple, Any from datetime import datetime # 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 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 .recorder import CameraRecorder from .monitor import CameraMonitor +from .sdk_config import initialize_sdk_with_suppression class CameraManager: """Manages all cameras in the system""" - + def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem): self.config = config self.state_manager = state_manager self.event_system = event_system self.logger = logging.getLogger(__name__) - + + # Initialize SDK early to suppress error messages + initialize_sdk_with_suppression() + # Camera management self.available_cameras: List[Any] = [] # mvsdk camera device info self.camera_recorders: Dict[str, CameraRecorder] = {} # camera_name -> recorder self.camera_monitor: Optional[CameraMonitor] = None - + # Threading self._lock = threading.RLock() self.running = False - + # Subscribe to machine state changes self.event_system.subscribe(EventType.MACHINE_STATE_CHANGED, self._on_machine_state_changed) - + # Initialize camera discovery self._discover_cameras() - + # Create camera monitor - self.camera_monitor = CameraMonitor( - config=config, - state_manager=state_manager, - event_system=event_system, - camera_manager=self - ) - + self.camera_monitor = CameraMonitor(config=config, state_manager=state_manager, event_system=event_system, camera_manager=self) + def start(self) -> bool: """Start the camera manager""" if self.running: self.logger.warning("Camera manager is already running") return True - + self.logger.info("Starting camera manager...") self.running = True - + # Start camera monitor if self.camera_monitor: self.camera_monitor.start() - + # Initialize camera recorders self._initialize_recorders() - + self.logger.info("Camera manager started successfully") return True - + def stop(self) -> None: """Stop the camera manager""" if not self.running: return - + self.logger.info("Stopping camera manager...") self.running = False - + # Stop camera monitor if self.camera_monitor: self.camera_monitor.stop() - + # Stop all active recordings with self._lock: for recorder in self.camera_recorders.values(): if recorder.is_recording(): recorder.stop_recording() recorder.cleanup() - + self.logger.info("Camera manager stopped") - + def _discover_cameras(self) -> None: """Discover available GigE cameras""" try: self.logger.info("Discovering GigE cameras...") - + # Enumerate cameras using mvsdk device_list = mvsdk.CameraEnumerateDevice() self.available_cameras = device_list - + self.logger.info(f"Found {len(device_list)} camera(s)") - + for i, dev_info in enumerate(device_list): try: name = dev_info.GetFriendlyName() 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}") - + # Update state manager with discovered camera camera_name = f"camera{i+1}" # Default naming - 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 - } - ) - + 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}) + except Exception as e: self.logger.error(f"Error processing camera {i}: {e}") - + except Exception as e: self.logger.error(f"Error discovering cameras: {e}") self.available_cameras = [] - + def _initialize_recorders(self) -> None: """Initialize camera recorders for configured cameras""" with self._lock: for camera_config in self.config.cameras: if not camera_config.enabled: continue - + try: # Find matching physical camera device_info = self._find_camera_device(camera_config.name) if device_info is None: self.logger.warning(f"No physical camera found for configured camera: {camera_config.name}") # Update state to indicate camera is not available - self.state_manager.update_camera_status( - name=camera_config.name, - status="not_found", - device_info=None - ) + self.state_manager.update_camera_status(name=camera_config.name, status="not_found", device_info=None) continue - # Create recorder (this will attempt to initialize the camera) - recorder = CameraRecorder( - 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 + # Create recorder (uses lazy initialization - camera will be initialized when recording starts) + recorder = CameraRecorder(camera_config=camera_config, device_info=device_info, state_manager=self.state_manager, event_system=self.event_system) + # Add recorder to the list (camera will be initialized lazily when needed) 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: self.logger.error(f"Error initializing recorder for {camera_config.name}: {e}") # Update state to indicate error - self.state_manager.update_camera_status( - name=camera_config.name, - status="error", - device_info={"error": str(e)} - ) - + self.state_manager.update_camera_status(name=camera_config.name, status="error", device_info={"error": str(e)}) + def _find_camera_device(self, camera_name: str) -> Optional[Any]: """Find physical camera device for a configured camera""" # For now, use simple mapping: camera1 -> device 0, camera2 -> device 1, etc. # This could be enhanced to use serial numbers or other identifiers - - camera_index_map = { - "camera1": 0, - "camera2": 1, - "camera3": 2, - "camera4": 3 - } - + + camera_index_map = {"camera1": 0, "camera2": 1, "camera3": 2, "camera4": 3} + device_index = camera_index_map.get(camera_name) if device_index is not None and device_index < len(self.available_cameras): return self.available_cameras[device_index] - + return None - + def _on_machine_state_changed(self, event: Event) -> None: """Handle machine state change events""" try: machine_name = event.data.get("machine_name") new_state = event.data.get("state") - + if not machine_name or not new_state: return - + self.logger.info(f"Handling machine state change: {machine_name} -> {new_state}") - + # Find camera associated with this machine camera_config = None for config in self.config.cameras: if config.machine_topic == machine_name: camera_config = config break - + if not camera_config: self.logger.warning(f"No camera configured for machine: {machine_name}") return - + # Get the recorder for this camera recorder = self.camera_recorders.get(camera_config.name) if not recorder: self.logger.warning(f"No recorder found for camera: {camera_config.name}") return - + # Handle state change if new_state == "on": self._start_recording(camera_config.name, recorder) elif new_state in ["off", "error"]: self._stop_recording(camera_config.name, recorder) - + except Exception as e: self.logger.error(f"Error handling machine state change: {e}") - + def _start_recording(self, camera_name: str, recorder: CameraRecorder) -> None: """Start recording for a camera""" try: if recorder.is_recording(): self.logger.info(f"Camera {camera_name} is already recording") return - + # Generate filename with Atlanta timezone timestamp timestamp = format_filename_timestamp() filename = f"{camera_name}_recording_{timestamp}.avi" - + # Start recording success = recorder.start_recording(filename) if success: self.logger.info(f"Started recording for camera {camera_name}: {filename}") else: self.logger.error(f"Failed to start recording for camera {camera_name}") - + except Exception as e: self.logger.error(f"Error starting recording for {camera_name}: {e}") - + def _stop_recording(self, camera_name: str, recorder: CameraRecorder) -> None: """Stop recording for a camera""" try: if not recorder.is_recording(): self.logger.info(f"Camera {camera_name} is not recording") return - + # Stop recording success = recorder.stop_recording() if success: self.logger.info(f"Stopped recording for camera {camera_name}") else: self.logger.error(f"Failed to stop recording for camera {camera_name}") - + except Exception as e: self.logger.error(f"Error stopping recording for {camera_name}: {e}") - + def get_camera_status(self, camera_name: str) -> Optional[Dict[str, Any]]: """Get status of a specific camera""" recorder = self.camera_recorders.get(camera_name) if not recorder: return None - + return recorder.get_status() - + def get_all_camera_status(self) -> Dict[str, Dict[str, Any]]: """Get status of all cameras""" status = {} @@ -294,50 +256,174 @@ class CameraManager: for camera_name, recorder in self.camera_recorders.items(): status[camera_name] = recorder.get_status() return status - - def manual_start_recording(self, camera_name: str, filename: Optional[str] = None) -> bool: - """Manually start recording for a camera""" + + 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 with optional camera settings""" recorder = self.camera_recorders.get(camera_name) if not recorder: self.logger.error(f"Camera not found: {camera_name}") return False - - if not filename: - timestamp = format_filename_timestamp() + + # Update camera settings if provided + 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" - + return recorder.start_recording(filename) - + def manual_stop_recording(self, camera_name: str) -> bool: """Manually stop recording for a camera""" recorder = self.camera_recorders.get(camera_name) if not recorder: self.logger.error(f"Camera not found: {camera_name}") return False - + return recorder.stop_recording() - + def get_available_cameras(self) -> List[Dict[str, Any]]: """Get list of available physical cameras""" cameras = [] for i, dev_info in enumerate(self.available_cameras): try: - cameras.append({ - "index": i, - "name": dev_info.GetFriendlyName(), - "port_type": dev_info.GetPortType(), - "serial_number": getattr(dev_info, 'acSn', 'Unknown') - }) + cameras.append({"index": i, "name": dev_info.GetFriendlyName(), "port_type": dev_info.GetPortType(), "serial_number": getattr(dev_info, "acSn", "Unknown")}) except Exception as e: self.logger.error(f"Error getting info for camera {i}: {e}") - + return cameras - + def refresh_camera_discovery(self) -> int: """Refresh camera discovery and return number of cameras found""" self._discover_cameras() return len(self.available_cameras) - + def is_running(self) -> bool: """Check if camera manager is 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 diff --git a/usda_vision_system/camera/monitor.py b/usda_vision_system/camera/monitor.py index e4b5515..b7f6b22 100644 --- a/usda_vision_system/camera/monitor.py +++ b/usda_vision_system/camera/monitor.py @@ -9,240 +9,236 @@ import os import threading import time import logging +import contextlib from typing import Dict, List, Optional, Any # 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 from ..core.config import Config from ..core.state_manager import StateManager, CameraStatus from ..core.events import EventSystem, publish_camera_status_changed +from .sdk_config import ensure_sdk_initialized + + +@contextlib.contextmanager +def suppress_camera_errors(): + """Context manager to temporarily suppress camera SDK error output""" + # Save original file descriptors + original_stderr = os.dup(2) + original_stdout = os.dup(1) + + try: + # Redirect stderr and stdout to devnull + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, 2) # stderr + os.dup2(devnull, 1) # stdout (in case SDK uses stdout) + os.close(devnull) + + yield + + finally: + # Restore original file descriptors + os.dup2(original_stderr, 2) + os.dup2(original_stdout, 1) + os.close(original_stderr) + os.close(original_stdout) class CameraMonitor: """Monitors camera status and availability""" - + def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager=None): self.config = config self.state_manager = state_manager self.event_system = event_system self.camera_manager = camera_manager # Reference to camera manager self.logger = logging.getLogger(__name__) - + # Monitoring settings self.check_interval = config.system.camera_check_interval_seconds - + # Threading self.running = False self._thread: Optional[threading.Thread] = None self._stop_event = threading.Event() - + # Status tracking self.last_check_time: Optional[float] = None self.check_count = 0 self.error_count = 0 - + def start(self) -> bool: """Start camera monitoring""" if self.running: self.logger.warning("Camera monitor is already running") return True - + self.logger.info(f"Starting camera monitor (check interval: {self.check_interval}s)") self.running = True self._stop_event.clear() - + # Start monitoring thread self._thread = threading.Thread(target=self._monitoring_loop, daemon=True) self._thread.start() - + return True - + def stop(self) -> None: """Stop camera monitoring""" if not self.running: return - + self.logger.info("Stopping camera monitor...") self.running = False self._stop_event.set() - + if self._thread and self._thread.is_alive(): self._thread.join(timeout=5) - + self.logger.info("Camera monitor stopped") - + def _monitoring_loop(self) -> None: """Main monitoring loop""" self.logger.info("Camera monitoring loop started") - + while self.running and not self._stop_event.is_set(): try: self.last_check_time = time.time() self.check_count += 1 - + # Check all configured cameras self._check_all_cameras() - + # Wait for next check if self._stop_event.wait(self.check_interval): break - + except Exception as e: self.error_count += 1 self.logger.error(f"Error in camera monitoring loop: {e}") - + # Wait a bit before retrying if self._stop_event.wait(min(self.check_interval, 10)): break - + self.logger.info("Camera monitoring loop ended") - + def _check_all_cameras(self) -> None: """Check status of all configured cameras""" for camera_config in self.config.cameras: if not camera_config.enabled: continue - + try: self._check_camera_status(camera_config.name) except Exception as e: self.logger.error(f"Error checking camera {camera_config.name}: {e}") - + def _check_camera_status(self, camera_name: str) -> None: """Check status of a specific camera""" try: # Get current status from state manager current_info = self.state_manager.get_camera_status(camera_name) - + # Perform actual camera check status, details, device_info = self._perform_camera_check(camera_name) - + # Update state if changed old_status = current_info.status.value if current_info else "unknown" if old_status != status: - self.state_manager.update_camera_status( - name=camera_name, - status=status, - error=details if status == "error" else None, - device_info=device_info - ) - + self.state_manager.update_camera_status(name=camera_name, status=status, error=details if status == "error" else None, device_info=device_info) + # Publish status change event - publish_camera_status_changed( - camera_name=camera_name, - status=status, - details=details - ) - + publish_camera_status_changed(camera_name=camera_name, status=status, details=details) + self.logger.info(f"Camera {camera_name} status changed: {old_status} -> {status}") - + except Exception as e: self.logger.error(f"Error checking camera {camera_name}: {e}") - + # Update to error state - self.state_manager.update_camera_status( - name=camera_name, - status="error", - error=str(e) - ) - + self.state_manager.update_camera_status(name=camera_name, status="error", error=str(e)) + def _perform_camera_check(self, camera_name: str) -> tuple[str, str, Optional[Dict[str, Any]]]: """Perform actual camera availability check""" try: # Get camera device info from camera manager if not self.camera_manager: return "error", "Camera manager not available", None - + device_info = self.camera_manager._find_camera_device(camera_name) if not device_info: return "disconnected", "Camera device not found", None - + # Check if camera is already opened by another process 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) - if recorder and recorder.hCamera: - return "available", "Camera initialized and ready", self._get_device_info_dict(device_info) + if recorder and recorder.hCamera and recorder.recording: + return "available", "Camera recording (in use by system)", self._get_device_info_dict(device_info) else: return "busy", "Camera opened by another process", self._get_device_info_dict(device_info) - + # Try to initialize camera briefly to test availability 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 try: mvsdk.CameraSetTriggerMode(hCamera, 0) mvsdk.CameraPlay(hCamera) - + # Try to capture with short timeout pRawData, FrameHead = mvsdk.CameraGetImageBuffer(hCamera, 500) mvsdk.CameraReleaseImageBuffer(hCamera, pRawData) - + # Success - camera is available mvsdk.CameraUnInit(hCamera) return "available", "Camera test successful", self._get_device_info_dict(device_info) - + except mvsdk.CameraException as e: mvsdk.CameraUnInit(hCamera) if e.error_code == mvsdk.CAMERA_STATUS_TIME_OUT: return "available", "Camera available but slow response", self._get_device_info_dict(device_info) else: return "error", f"Camera test failed: {e.message}", self._get_device_info_dict(device_info) - + except mvsdk.CameraException as e: return "error", f"Camera initialization failed: {e.message}", self._get_device_info_dict(device_info) - + except Exception as e: return "error", f"Camera check failed: {str(e)}", None - + def _get_device_info_dict(self, device_info) -> Dict[str, Any]: """Convert device info to dictionary""" try: - return { - "friendly_name": device_info.GetFriendlyName(), - "port_type": device_info.GetPortType(), - "serial_number": getattr(device_info, 'acSn', 'Unknown'), - "last_checked": time.time() - } + return {"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: self.logger.error(f"Error getting device info: {e}") return {"error": str(e)} - + def check_camera_now(self, camera_name: str) -> Dict[str, Any]: """Manually check a specific camera status""" try: status, details, device_info = self._perform_camera_check(camera_name) - + # Update state - self.state_manager.update_camera_status( - name=camera_name, - status=status, - 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() - } - + self.state_manager.update_camera_status(name=camera_name, status=status, 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: error_msg = f"Manual camera check failed: {e}" self.logger.error(error_msg) - return { - "camera_name": camera_name, - "status": "error", - "details": error_msg, - "device_info": None, - "check_time": time.time() - } - + return {"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]]: """Manually check all cameras""" results = {} @@ -250,18 +246,11 @@ class CameraMonitor: if camera_config.enabled: results[camera_config.name] = self.check_camera_now(camera_config.name) return results - + def get_monitoring_stats(self) -> Dict[str, Any]: """Get monitoring statistics""" - 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 - } - + 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} + def is_running(self) -> bool: """Check if monitor is running""" return self.running diff --git a/usda_vision_system/camera/recorder.py b/usda_vision_system/camera/recorder.py index 2ba28d4..80c6fde 100644 --- a/usda_vision_system/camera/recorder.py +++ b/usda_vision_system/camera/recorder.py @@ -11,18 +11,44 @@ import time import logging import cv2 import numpy as np +import contextlib from typing import Optional, Dict, Any from datetime import datetime from pathlib import 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 from ..core.config import CameraConfig from ..core.state_manager import StateManager from ..core.events import EventSystem, publish_recording_started, publish_recording_stopped, publish_recording_error from ..core.timezone_utils import now_atlanta, format_filename_timestamp +from .sdk_config import ensure_sdk_initialized + + +@contextlib.contextmanager +def suppress_camera_errors(): + """Context manager to temporarily suppress camera SDK error output""" + # Save original file descriptors + original_stderr = os.dup(2) + original_stdout = os.dup(1) + + try: + # Redirect stderr and stdout to devnull + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, 2) # stderr + os.dup2(devnull, 1) # stdout (in case SDK uses stdout) + os.close(devnull) + + yield + + finally: + # Restore original file descriptors + os.dup2(original_stderr, 2) + os.dup2(original_stdout, 1) + os.close(original_stderr) + os.close(original_stdout) class CameraRecorder: @@ -35,41 +61,46 @@ class CameraRecorder: self.event_system = event_system self.storage_manager = storage_manager self.logger = logging.getLogger(f"{__name__}.{camera_config.name}") - + # Camera handle and properties self.hCamera: Optional[int] = None self.cap = None self.monoCamera = False self.frame_buffer = None self.frame_buffer_size = 0 - + # Recording state self.recording = False self.video_writer: Optional[cv2.VideoWriter] = None self.output_filename: Optional[str] = None self.frame_count = 0 self.start_time: Optional[datetime] = None - + # Threading self._recording_thread: Optional[threading.Thread] = None self._stop_recording_event = threading.Event() self._lock = threading.RLock() - - # Initialize camera - self._initialize_camera() - + + # Don't initialize camera immediately - use lazy initialization + # 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: """Initialize the camera with configured settings""" try: self.logger.info(f"Initializing camera: {self.camera_config.name}") + # Ensure SDK is initialized + ensure_sdk_initialized() + # Check if device_info is valid if self.device_info is None: self.logger.error("No device info provided for camera initialization") return False - # Initialize camera - self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1) + # Initialize camera (suppress output to avoid MVCAMAPI error messages) + with suppress_camera_errors(): + self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1) self.logger.info("Camera initialized successfully") # Get camera capabilities @@ -104,9 +135,7 @@ class CameraRecorder: # Allocate frame buffer based on bit depth bytes_per_pixel = self._get_bytes_per_pixel() - self.frame_buffer_size = (self.cap.sResolutionRange.iWidthMax * - self.cap.sResolutionRange.iHeightMax * - bytes_per_pixel) + self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16) # Start camera @@ -124,7 +153,30 @@ class CameraRecorder: except Exception as e: self.logger.error(f"Unexpected error during camera initialization: {e}") 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: """Configure camera settings from config""" try: @@ -174,8 +226,7 @@ class CameraRecorder: if not self.monoCamera: mvsdk.CameraSetSaturation(self.hCamera, self.camera_config.saturation) - self.logger.info(f"Image quality configured - Sharpness: {self.camera_config.sharpness}, " - f"Contrast: {self.camera_config.contrast}, Gamma: {self.camera_config.gamma}") + self.logger.info(f"Image quality configured - Sharpness: {self.camera_config.sharpness}, " f"Contrast: {self.camera_config.contrast}, Gamma: {self.camera_config.gamma}") except Exception as e: self.logger.warning(f"Error configuring image quality: {e}") @@ -194,8 +245,7 @@ class CameraRecorder: else: mvsdk.CameraSetDenoise3DParams(self.hCamera, False, 2, None) - self.logger.info(f"Noise reduction configured - Filter: {self.camera_config.noise_filter_enabled}, " - f"3D Denoise: {self.camera_config.denoise_3d_enabled}") + self.logger.info(f"Noise reduction configured - Filter: {self.camera_config.noise_filter_enabled}, " f"3D Denoise: {self.camera_config.denoise_3d_enabled}") except Exception as e: self.logger.warning(f"Error configuring noise reduction: {e}") @@ -210,8 +260,7 @@ class CameraRecorder: if not self.camera_config.auto_white_balance: 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}, " - f"Color Temp Preset: {self.camera_config.color_temperature_preset}") + 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}") except Exception as e: self.logger.warning(f"Error configuring color settings: {e}") @@ -225,61 +274,104 @@ class CameraRecorder: # Set light frequency (0=50Hz, 1=60Hz) mvsdk.CameraSetLightFrequency(self.hCamera, self.camera_config.light_frequency) - # Configure HDR if enabled - if self.camera_config.hdr_enabled: - mvsdk.CameraSetHDR(self.hCamera, 1) # Enable HDR - mvsdk.CameraSetHDRGainMode(self.hCamera, self.camera_config.hdr_gain_mode) - self.logger.info(f"HDR enabled with gain mode: {self.camera_config.hdr_gain_mode}") - else: - mvsdk.CameraSetHDR(self.hCamera, 0) # Disable HDR + # Configure HDR if enabled (check if HDR functions are available) + try: + if self.camera_config.hdr_enabled: + mvsdk.CameraSetHDR(self.hCamera, 1) # Enable HDR + mvsdk.CameraSetHDRGainMode(self.hCamera, self.camera_config.hdr_gain_mode) + self.logger.info(f"HDR enabled with gain mode: {self.camera_config.hdr_gain_mode}") + 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}, " - f"Light Freq: {self.camera_config.light_frequency}Hz, HDR: {self.camera_config.hdr_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}") except Exception as 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: """Start video recording""" with self._lock: if self.recording: self.logger.warning("Already recording!") return False - + + # Initialize camera if not already initialized (lazy initialization) if not self.hCamera: - self.logger.error("Camera not initialized") - return False - + self.logger.info("Camera not initialized, initializing now...") + if not self._initialize_camera(): + self.logger.error("Failed to initialize camera for recording") + return False + try: # Prepare output path output_path = os.path.join(self.camera_config.storage_path, filename) Path(self.camera_config.storage_path).mkdir(parents=True, exist_ok=True) - + # Test camera capture before starting recording if not self._test_camera_capture(): self.logger.error("Camera capture test failed") return False - + # Initialize recording state self.output_filename = output_path self.frame_count = 0 self.start_time = now_atlanta() # Use Atlanta timezone self._stop_recording_event.clear() - + # Start recording thread self._recording_thread = threading.Thread(target=self._recording_loop, daemon=True) self._recording_thread.start() - + # Update state self.recording = True recording_id = self.state_manager.start_recording(self.camera_config.name, output_path) - + # Publish event publish_recording_started(self.camera_config.name, output_path) - + self.logger.info(f"Started recording to: {output_path}") return True - + except Exception as e: self.logger.error(f"Error starting recording: {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) # Publish event - publish_recording_stopped( - self.camera_config.name, - self.output_filename or "unknown", - duration - ) + publish_recording_stopped(self.camera_config.name, self.output_filename or "unknown", duration) + + # Clean up camera resources after recording (lazy cleanup) + 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}") return True @@ -402,18 +494,13 @@ class CameraRecorder: mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData) # Set up video writer - fourcc = cv2.VideoWriter_fourcc(*'XVID') + fourcc = cv2.VideoWriter_fourcc(*"XVID") frame_size = (FrameHead.iWidth, FrameHead.iHeight) # 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 - self.video_writer = cv2.VideoWriter( - self.output_filename, - fourcc, - video_fps, - frame_size - ) + self.video_writer = cv2.VideoWriter(self.output_filename, fourcc, video_fps, frame_size) if not self.video_writer.isOpened(): 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 # 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 = 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) + # Handle different bit depths + if self.camera_config.bit_depth > 8: + # For >8-bit, data is stored as 16-bit values + 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: - # Color camera - already in BGR format - frame_bgr = frame_data.reshape((frame_head.iHeight, frame_head.iWidth, 3)) + # 8-bit data + 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 @@ -460,6 +566,175 @@ class CameraRecorder: except Exception as 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: """Clean up camera resources""" try: @@ -488,12 +763,4 @@ class CameraRecorder: def get_status(self) -> Dict[str, Any]: """Get recorder status""" - 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 - } + 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} diff --git a/usda_vision_system/camera/sdk_config.py b/usda_vision_system/camera/sdk_config.py new file mode 100644 index 0000000..9bf97d9 --- /dev/null +++ b/usda_vision_system/camera/sdk_config.py @@ -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 diff --git a/usda_vision_system/core/__pycache__/config.cpython-311.pyc b/usda_vision_system/core/__pycache__/config.cpython-311.pyc index 0add6ec..d9b3a97 100644 Binary files a/usda_vision_system/core/__pycache__/config.cpython-311.pyc and b/usda_vision_system/core/__pycache__/config.cpython-311.pyc differ diff --git a/usda_vision_system/core/__pycache__/state_manager.cpython-311.pyc b/usda_vision_system/core/__pycache__/state_manager.cpython-311.pyc index b1ca184..822dd62 100644 Binary files a/usda_vision_system/core/__pycache__/state_manager.cpython-311.pyc and b/usda_vision_system/core/__pycache__/state_manager.cpython-311.pyc differ diff --git a/usda_vision_system/core/state_manager.py b/usda_vision_system/core/state_manager.py index b308727..9f3732d 100644 --- a/usda_vision_system/core/state_manager.py +++ b/usda_vision_system/core/state_manager.py @@ -9,12 +9,13 @@ import threading import logging from typing import Dict, Optional, List, Any from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timedelta from enum import Enum class MachineState(Enum): """Machine states""" + UNKNOWN = "unknown" ON = "on" OFF = "off" @@ -23,6 +24,7 @@ class MachineState(Enum): class CameraStatus(Enum): """Camera status""" + UNKNOWN = "unknown" AVAILABLE = "available" BUSY = "busy" @@ -32,6 +34,7 @@ class CameraStatus(Enum): class RecordingState(Enum): """Recording states""" + IDLE = "idle" RECORDING = "recording" STOPPING = "stopping" @@ -41,6 +44,7 @@ class RecordingState(Enum): @dataclass class MachineInfo: """Machine state information""" + name: str state: MachineState = MachineState.UNKNOWN last_updated: datetime = field(default_factory=datetime.now) @@ -48,9 +52,22 @@ class MachineInfo: 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 class CameraInfo: """Camera state information""" + name: str status: CameraStatus = CameraStatus.UNKNOWN last_checked: datetime = field(default_factory=datetime.now) @@ -64,6 +81,7 @@ class CameraInfo: @dataclass class RecordingInfo: """Recording session information""" + camera_name: str filename: str start_time: datetime @@ -76,21 +94,26 @@ class RecordingInfo: class StateManager: """Thread-safe state manager for the entire system""" - + def __init__(self): self.logger = logging.getLogger(__name__) self._lock = threading.RLock() - + # State dictionaries self._machines: Dict[str, MachineInfo] = {} self._cameras: Dict[str, CameraInfo] = {} 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 self._mqtt_connected = False self._system_started = False self._last_mqtt_message_time: Optional[datetime] = None - + # Machine state management def update_machine_state(self, name: str, state: str, message: Optional[str] = None, topic: Optional[str] = None) -> bool: """Update machine state""" @@ -99,11 +122,11 @@ class StateManager: except ValueError: self.logger.warning(f"Invalid machine state: {state}") machine_state = MachineState.UNKNOWN - + with self._lock: if name not in self._machines: self._machines[name] = MachineInfo(name=name, mqtt_topic=topic) - + machine = self._machines[name] old_state = machine.state machine.state = machine_state @@ -111,20 +134,47 @@ class StateManager: machine.last_message = message if topic: machine.mqtt_topic = topic - + self.logger.info(f"Machine {name} state: {old_state.value} -> {machine_state.value}") return old_state != machine_state - + def get_machine_state(self, name: str) -> Optional[MachineInfo]: """Get machine state""" with self._lock: return self._machines.get(name) - + def get_all_machines(self) -> Dict[str, MachineInfo]: """Get all machine states""" with self._lock: 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 def update_camera_status(self, name: str, status: str, error: Optional[str] = None, device_info: Optional[Dict] = None) -> bool: """Update camera status""" @@ -133,11 +183,11 @@ class StateManager: except ValueError: self.logger.warning(f"Invalid camera status: {status}") camera_status = CameraStatus.UNKNOWN - + with self._lock: if name not in self._cameras: self._cameras[name] = CameraInfo(name=name) - + camera = self._cameras[name] old_status = camera.status camera.status = camera_status @@ -145,113 +195,106 @@ class StateManager: camera.last_error = error if device_info: camera.device_info = device_info - + if old_status != camera_status: self.logger.info(f"Camera {name} status: {old_status.value} -> {camera_status.value}") return True return False - + def set_camera_recording(self, name: str, recording: bool, filename: Optional[str] = None) -> None: """Set camera recording state""" with self._lock: if name not in self._cameras: self._cameras[name] = CameraInfo(name=name) - + camera = self._cameras[name] camera.is_recording = recording camera.current_recording_file = filename - + if recording and filename: camera.recording_start_time = datetime.now() self.logger.info(f"Camera {name} started recording: {filename}") elif not recording: camera.recording_start_time = None self.logger.info(f"Camera {name} stopped recording") - + def get_camera_status(self, name: str) -> Optional[CameraInfo]: """Get camera status""" with self._lock: return self._cameras.get(name) - + def get_all_cameras(self) -> Dict[str, CameraInfo]: """Get all camera statuses""" with self._lock: return self._cameras.copy() - + # Recording management def start_recording(self, camera_name: str, filename: str) -> str: """Start a new recording session""" recording_id = filename # Use filename as recording ID - + with self._lock: - recording = RecordingInfo( - camera_name=camera_name, - filename=filename, - start_time=datetime.now() - ) + recording = RecordingInfo(camera_name=camera_name, filename=filename, start_time=datetime.now()) self._recordings[recording_id] = recording - + # Update camera state self.set_camera_recording(camera_name, True, filename) - + self.logger.info(f"Started recording session: {recording_id}") return recording_id - + def stop_recording(self, recording_id: str, file_size: Optional[int] = None, frame_count: Optional[int] = None) -> bool: """Stop a recording session""" with self._lock: if recording_id not in self._recordings: self.logger.warning(f"Recording session not found: {recording_id}") return False - + recording = self._recordings[recording_id] recording.state = RecordingState.IDLE recording.end_time = datetime.now() recording.file_size_bytes = file_size recording.frame_count = frame_count - + # Update camera state self.set_camera_recording(recording.camera_name, False) - + duration = (recording.end_time - recording.start_time).total_seconds() self.logger.info(f"Stopped recording session: {recording_id} (duration: {duration:.1f}s)") return True - + def set_recording_error(self, recording_id: str, error_message: str) -> bool: """Set recording error state""" with self._lock: if recording_id not in self._recordings: return False - + recording = self._recordings[recording_id] recording.state = RecordingState.ERROR recording.error_message = error_message recording.end_time = datetime.now() - + # Update camera state self.set_camera_recording(recording.camera_name, False) - + self.logger.error(f"Recording error for {recording_id}: {error_message}") return True - + def get_recording(self, recording_id: str) -> Optional[RecordingInfo]: """Get recording information""" with self._lock: return self._recordings.get(recording_id) - + def get_all_recordings(self) -> Dict[str, RecordingInfo]: """Get all recording sessions""" with self._lock: return self._recordings.copy() - + def get_active_recordings(self) -> Dict[str, RecordingInfo]: """Get currently active recordings""" with self._lock: - return { - rid: recording for rid, recording in self._recordings.items() - if recording.state == RecordingState.RECORDING - } - + return {rid: recording for rid, recording in self._recordings.items() if recording.state == RecordingState.RECORDING} + # System state management def set_mqtt_connected(self, connected: bool) -> None: """Set MQTT connection state""" @@ -260,31 +303,31 @@ class StateManager: self._mqtt_connected = connected if connected: self._last_mqtt_message_time = datetime.now() - + if old_state != connected: self.logger.info(f"MQTT connection: {'connected' if connected else 'disconnected'}") - + def is_mqtt_connected(self) -> bool: """Check if MQTT is connected""" with self._lock: return self._mqtt_connected - + def update_mqtt_activity(self) -> None: """Update last MQTT message time""" with self._lock: self._last_mqtt_message_time = datetime.now() - + def set_system_started(self, started: bool) -> None: """Set system started state""" with self._lock: self._system_started = started self.logger.info(f"System {'started' if started else 'stopped'}") - + def is_system_started(self) -> bool: """Check if system is started""" with self._lock: return self._system_started - + # Utility methods def get_system_summary(self) -> Dict[str, Any]: """Get a summary of the entire system state""" @@ -293,36 +336,28 @@ class StateManager: "system_started": self._system_started, "mqtt_connected": self._mqtt_connected, "last_mqtt_message": self._last_mqtt_message_time.isoformat() if self._last_mqtt_message_time else None, - "machines": {name: { - "state": machine.state.value, - "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()}, + "machines": {name: {"state": machine.state.value, "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()), - "total_recordings": len(self._recordings) + "total_recordings": len(self._recordings), } - + def cleanup_old_recordings(self, max_age_hours: int = 24) -> int: """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 - + with self._lock: to_remove = [] for recording_id, recording in self._recordings.items(): - if (recording.state != RecordingState.RECORDING and - recording.end_time and recording.end_time < cutoff_time): + if recording.state != RecordingState.RECORDING and recording.end_time and recording.end_time < cutoff_time: to_remove.append(recording_id) - + for recording_id in to_remove: del self._recordings[recording_id] removed_count += 1 - + if removed_count > 0: self.logger.info(f"Cleaned up {removed_count} old recording entries") - + return removed_count diff --git a/usda_vision_system/mqtt/__pycache__/client.cpython-311.pyc b/usda_vision_system/mqtt/__pycache__/client.cpython-311.pyc index 738a8a7..b89658f 100644 Binary files a/usda_vision_system/mqtt/__pycache__/client.cpython-311.pyc and b/usda_vision_system/mqtt/__pycache__/client.cpython-311.pyc differ diff --git a/usda_vision_system/mqtt/__pycache__/handlers.cpython-311.pyc b/usda_vision_system/mqtt/__pycache__/handlers.cpython-311.pyc index c45f7c4..37a3910 100644 Binary files a/usda_vision_system/mqtt/__pycache__/handlers.cpython-311.pyc and b/usda_vision_system/mqtt/__pycache__/handlers.cpython-311.pyc differ diff --git a/usda_vision_system/mqtt/client.py b/usda_vision_system/mqtt/client.py index e3648db..6fea69a 100644 --- a/usda_vision_system/mqtt/client.py +++ b/usda_vision_system/mqtt/client.py @@ -7,7 +7,7 @@ This module provides MQTT connectivity and message handling for machine state up import threading import time import logging -from typing import Dict, Optional, Callable, List +from typing import Dict, Optional, Any import paho.mqtt.client as mqtt from ..core.config import Config, MQTTConfig @@ -18,207 +18,219 @@ from .handlers import MQTTMessageHandler class MQTTClient: """MQTT client for receiving machine state updates""" - + def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem): self.config = config self.mqtt_config = config.mqtt self.state_manager = state_manager self.event_system = event_system self.logger = logging.getLogger(__name__) - + # MQTT client self.client: Optional[mqtt.Client] = None self.connected = False self.running = False - + # Threading self._thread: Optional[threading.Thread] = None self._stop_event = threading.Event() - + # Message handler self.message_handler = MQTTMessageHandler(state_manager, event_system) - + # Connection retry settings self.reconnect_delay = 5 # seconds self.max_reconnect_attempts = 10 - + # Topic mapping (topic -> machine_name) - self.topic_to_machine = { - topic: machine_name - for machine_name, topic in self.mqtt_config.topics.items() - } - + self.topic_to_machine = {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: """Start the MQTT client in a separate thread""" if self.running: self.logger.warning("MQTT client is already running") return True - + self.logger.info("Starting MQTT client...") self.running = True self._stop_event.clear() - + self.start_time = time.time() + # Start in separate thread self._thread = threading.Thread(target=self._run_loop, daemon=True) self._thread.start() - + # Wait a moment to see if connection succeeds time.sleep(2) return self.connected - + def stop(self) -> None: """Stop the MQTT client""" if not self.running: return - + self.logger.info("Stopping MQTT client...") self.running = False self._stop_event.set() - + if self.client and self.connected: self.client.disconnect() - + if self._thread and self._thread.is_alive(): self._thread.join(timeout=5) - + self.logger.info("MQTT client stopped") - + def _run_loop(self) -> None: """Main MQTT client loop""" reconnect_attempts = 0 - + while self.running and not self._stop_event.is_set(): try: if not self.connected: if self._connect(): reconnect_attempts = 0 - self._subscribe_to_topics() else: reconnect_attempts += 1 if reconnect_attempts >= self.max_reconnect_attempts: self.logger.error(f"Max reconnection attempts ({self.max_reconnect_attempts}) reached") break - + 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): break continue - + # Process MQTT messages if self.client: self.client.loop(timeout=1.0) - + # Small delay to prevent busy waiting if self._stop_event.wait(0.1): break - + except Exception as e: self.logger.error(f"Error in MQTT loop: {e}") self.connected = False if self._stop_event.wait(self.reconnect_delay): break - + self.running = False self.logger.info("MQTT client loop ended") - + def _connect(self) -> bool: """Connect to MQTT broker""" try: # Create new client instance 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 - + # Set authentication if provided if self.mqtt_config.username and self.mqtt_config.password: - self.client.username_pw_set( - self.mqtt_config.username, - self.mqtt_config.password - ) - + self.client.username_pw_set(self.mqtt_config.username, self.mqtt_config.password) + # Connect to broker self.logger.info(f"Connecting to MQTT broker at {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}") - self.client.connect( - self.mqtt_config.broker_host, - self.mqtt_config.broker_port, - 60 - ) - + self.client.connect(self.mqtt_config.broker_host, self.mqtt_config.broker_port, 60) + return True - + except Exception as e: self.logger.error(f"Failed to connect to MQTT broker: {e}") return False - + def _subscribe_to_topics(self) -> None: """Subscribe to all configured topics""" if not self.client or not self.connected: return - + for machine_name, topic in self.mqtt_config.topics.items(): try: result, mid = self.client.subscribe(topic) 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: - 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: self.logger.error(f"Error subscribing to topic {topic}: {e}") - + def _on_connect(self, client, userdata, flags, rc) -> None: """Callback for when the client connects to the broker""" if rc == 0: self.connected = True self.state_manager.set_mqtt_connected(True) 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: 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: """Callback for when the client disconnects from the broker""" self.connected = False self.state_manager.set_mqtt_connected(False) self.event_system.publish(EventType.MQTT_DISCONNECTED, "mqtt_client") - + 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: - 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: """Callback for when a message is received""" try: 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}") - - # Update MQTT activity + + # Update MQTT activity and tracking self.state_manager.update_mqtt_activity() - + self.message_count += 1 + self.last_message_time = time.time() + # Get machine name from topic machine_name = self.topic_to_machine.get(topic) 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 - + + # Show MQTT message on console + print(f"๐Ÿ“ก MQTT MESSAGE: {machine_name} โ†’ {payload}") + # Handle the message self.message_handler.handle_message(machine_name, topic, payload) - + except Exception as e: + self.error_count += 1 self.logger.error(f"Error processing MQTT message: {e}") - + def publish_message(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool: """Publish a message to MQTT broker""" if not self.client or not self.connected: self.logger.warning("Cannot publish: MQTT client not connected") return False - + try: result = self.client.publish(topic, payload, qos, retain) if result.rc == mqtt.MQTT_ERR_SUCCESS: @@ -230,22 +242,26 @@ class MQTTClient: except Exception as e: self.logger.error(f"Error publishing message: {e}") return False - - def get_status(self) -> Dict[str, any]: + + def get_status(self) -> Dict[str, Any]: """Get MQTT client status""" - 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 - } - + uptime_seconds = None + last_message_time_str = None + + if self.start_time: + uptime_seconds = time.time() - self.start_time + + 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: """Check if MQTT client is connected""" return self.connected - + def is_running(self) -> bool: """Check if MQTT client is running""" return self.running diff --git a/usda_vision_system/mqtt/handlers.py b/usda_vision_system/mqtt/handlers.py index 8e2330f..f890ecd 100644 --- a/usda_vision_system/mqtt/handlers.py +++ b/usda_vision_system/mqtt/handlers.py @@ -14,69 +14,63 @@ from ..core.events import EventSystem, publish_machine_state_changed class MQTTMessageHandler: """Handles MQTT messages and triggers appropriate system actions""" - + def __init__(self, state_manager: StateManager, event_system: EventSystem): self.state_manager = state_manager self.event_system = event_system self.logger = logging.getLogger(__name__) - + # Message processing statistics self.message_count = 0 self.last_message_time: Optional[datetime] = None self.error_count = 0 - + def handle_message(self, machine_name: str, topic: str, payload: str) -> None: """Handle an incoming MQTT message""" try: self.message_count += 1 self.last_message_time = datetime.now() - + self.logger.info(f"Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: {payload}") - + # Normalize payload normalized_payload = self._normalize_payload(payload) - + # Update machine state - state_changed = self.state_manager.update_machine_state( - name=machine_name, - state=normalized_payload, - message=payload, - topic=topic - ) - + state_changed = self.state_manager.update_machine_state(name=machine_name, state=normalized_payload, message=payload, topic=topic) + + # Store MQTT event in history + self.state_manager.add_mqtt_event(machine_name=machine_name, topic=topic, payload=payload, normalized_state=normalized_payload) + # Publish state change event if state actually changed if state_changed: - publish_machine_state_changed( - machine_name=machine_name, - state=normalized_payload, - source="mqtt_handler" - ) - + publish_machine_state_changed(machine_name=machine_name, state=normalized_payload, source="mqtt_handler") + self.logger.info(f"Machine {machine_name} state changed to: {normalized_payload}") - + # Log the message for debugging self._log_message_details(machine_name, topic, payload, normalized_payload) - + except Exception as e: self.error_count += 1 self.logger.error(f"Error handling MQTT message for {machine_name}: {e}") - + def _normalize_payload(self, payload: str) -> str: """Normalize payload to standard machine states""" payload_lower = payload.lower().strip() - + # Map various possible payloads to standard states - if payload_lower in ['on', 'true', '1', 'start', 'running', 'active']: - return 'on' - elif payload_lower in ['off', 'false', '0', 'stop', 'stopped', 'inactive']: - return 'off' - elif payload_lower in ['error', 'fault', 'alarm']: - return 'error' + if payload_lower in ["on", "true", "1", "start", "running", "active"]: + return "on" + elif payload_lower in ["off", "false", "0", "stop", "stopped", "inactive"]: + return "off" + elif payload_lower in ["error", "fault", "alarm"]: + return "error" else: # For unknown payloads, log and return as-is self.logger.warning(f"Unknown payload format: '{payload}', treating as raw state") return payload_lower - + def _log_message_details(self, machine_name: str, topic: str, original_payload: str, normalized_payload: str) -> None: """Log detailed message information""" 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" Timestamp: {self.last_message_time}") self.logger.debug(f" Total Messages Processed: {self.message_count}") - + def get_statistics(self) -> Dict[str, any]: """Get message processing statistics""" - 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 - } - + 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} + def reset_statistics(self) -> None: """Reset message processing statistics""" self.message_count = 0 @@ -106,47 +95,47 @@ class MQTTMessageHandler: class MachineStateProcessor: """Processes machine state changes and determines actions""" - + def __init__(self, state_manager: StateManager, event_system: EventSystem): self.state_manager = state_manager self.event_system = event_system self.logger = logging.getLogger(__name__) - + 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""" self.logger.info(f"Processing state change for {machine_name}: {old_state} -> {new_state}") - + # 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) - elif old_state == 'on' and new_state != 'on': + elif old_state == "on" and new_state != "on": self._handle_machine_turned_off(machine_name) - elif new_state == 'error': + elif new_state == "error": self._handle_machine_error(machine_name) - + def _handle_machine_turned_on(self, machine_name: str) -> None: """Handle machine turning 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 # which listens to the MACHINE_STATE_CHANGED event - + # We could add additional logic here, such as: # - Checking if camera is available # - Pre-warming camera settings # - Sending notifications - + def _handle_machine_turned_off(self, machine_name: str) -> None: """Handle machine turning 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 # which listens to the MACHINE_STATE_CHANGED event - + def _handle_machine_error(self, machine_name: str) -> None: """Handle machine error state""" self.logger.warning(f"Machine {machine_name} in ERROR state") - + # Could implement error handling logic here: # - Stop recording if active # - Send alerts diff --git a/usda_vision_system/storage/__pycache__/manager.cpython-311.pyc b/usda_vision_system/storage/__pycache__/manager.cpython-311.pyc index 19acc5c..249e40a 100644 Binary files a/usda_vision_system/storage/__pycache__/manager.cpython-311.pyc and b/usda_vision_system/storage/__pycache__/manager.cpython-311.pyc differ diff --git a/usda_vision_system/storage/manager.py b/usda_vision_system/storage/manager.py index 5e959bb..0293ea6 100644 --- a/usda_vision_system/storage/manager.py +++ b/usda_vision_system/storage/manager.py @@ -19,7 +19,7 @@ from ..core.events import EventSystem, EventType, Event class StorageManager: """Manages storage and file organization for recorded videos""" - + def __init__(self, config: Config, state_manager: StateManager, event_system: Optional[EventSystem] = None): self.config = config self.storage_config = config.storage @@ -37,20 +37,20 @@ class StorageManager: # Subscribe to recording events if event system is available if self.event_system: self._setup_event_subscriptions() - + def _ensure_storage_structure(self) -> None: """Ensure storage directory structure exists""" try: # Create base storage directory Path(self.storage_config.base_path).mkdir(parents=True, exist_ok=True) - + # Create camera-specific directories for camera_config in self.config.cameras: Path(camera_config.storage_path).mkdir(parents=True, exist_ok=True) self.logger.debug(f"Ensured storage directory: {camera_config.storage_path}") - + self.logger.info("Storage directory structure verified") - + except Exception as e: self.logger.error(f"Error creating storage structure: {e}") raise @@ -66,12 +66,7 @@ class StorageManager: camera_name = event.data.get("camera_name") filename = event.data.get("filename") if camera_name and filename: - self.register_recording_file( - camera_name=camera_name, - filename=filename, - start_time=event.timestamp, - machine_trigger=event.data.get("machine_trigger") - ) + self.register_recording_file(camera_name=camera_name, filename=filename, start_time=event.timestamp, machine_trigger=event.data.get("machine_trigger")) except Exception as e: self.logger.error(f"Error handling recording started event: {e}") @@ -81,64 +76,48 @@ class StorageManager: filename = event.data.get("filename") if filename: file_id = os.path.basename(filename) - self.finalize_recording_file( - file_id=file_id, - end_time=event.timestamp, - duration_seconds=event.data.get("duration_seconds") - ) + self.finalize_recording_file(file_id=file_id, end_time=event.timestamp, duration_seconds=event.data.get("duration_seconds")) except Exception as e: self.logger.error(f"Error handling recording stopped event: {e}") # Subscribe to recording events self.event_system.subscribe(EventType.RECORDING_STARTED, on_recording_started) self.event_system.subscribe(EventType.RECORDING_STOPPED, on_recording_stopped) - + def _load_file_index(self) -> Dict[str, Any]: """Load file index from disk""" try: 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) else: return {"files": {}, "last_updated": None} except Exception as e: self.logger.error(f"Error loading file index: {e}") return {"files": {}, "last_updated": None} - + def _save_file_index(self) -> None: """Save file index to disk""" try: 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) except Exception as e: self.logger.error(f"Error saving file index: {e}") - - def register_recording_file(self, camera_name: str, filename: str, start_time: datetime, - machine_trigger: Optional[str] = None) -> str: + + def register_recording_file(self, camera_name: str, filename: str, start_time: datetime, machine_trigger: Optional[str] = None) -> str: """Register a new recording file""" try: file_id = os.path.basename(filename) - - 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() - } - + + 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()} + self.file_index["files"][file_id] = file_info self._save_file_index() - + self.logger.info(f"Registered recording file: {file_id}") return file_id - + except Exception as e: self.logger.error(f"Error registering recording file: {e}") return "" @@ -169,52 +148,50 @@ class StorageManager: except Exception as e: self.logger.error(f"Error finalizing recording file: {e}") return False - - def finalize_recording_file(self, file_id: str, end_time: datetime, - duration_seconds: float, frame_count: Optional[int] = None) -> bool: + + def finalize_recording_file(self, file_id: str, end_time: datetime, duration_seconds: float, frame_count: Optional[int] = None) -> bool: """Finalize a recording file after recording stops""" try: if file_id not in self.file_index["files"]: self.logger.warning(f"File ID not found in index: {file_id}") return False - + file_info = self.file_index["files"][file_id] filename = file_info["filename"] - + # Update file information file_info["end_time"] = end_time.isoformat() file_info["duration_seconds"] = duration_seconds file_info["status"] = "completed" - + # Get file size if file exists if os.path.exists(filename): file_info["file_size_bytes"] = os.path.getsize(filename) - + if frame_count is not None: file_info["frame_count"] = frame_count - + self._save_file_index() - + self.logger.info(f"Finalized recording file: {file_id} (duration: {duration_seconds:.1f}s)") return True - + except Exception as e: self.logger.error(f"Error finalizing recording file: {e}") return False - - 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]]: + + 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]]: """Get list of recording files with optional filters""" try: files = [] - + + # First, get files from the index (if available) + indexed_files = set() for file_id, file_info in self.file_index["files"].items(): # Filter by camera name if camera_name and file_info["camera_name"] != camera_name: continue - + # Filter by date range if start_date or end_date: file_start = datetime.fromisoformat(file_info["start_time"]) @@ -222,88 +199,106 @@ class StorageManager: continue if end_date and file_start > end_date: continue - + 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) files.sort(key=lambda x: x["start_time"], reverse=True) - + # Apply limit if limit: files = files[:limit] - + return files - + except Exception as e: self.logger.error(f"Error getting recording files: {e}") return [] - + def get_storage_statistics(self) -> Dict[str, Any]: """Get storage usage statistics""" try: - stats = { - "base_path": self.storage_config.base_path, - "total_files": 0, - "total_size_bytes": 0, - "cameras": {}, - "disk_usage": {} - } - + stats = {"base_path": self.storage_config.base_path, "total_files": 0, "total_size_bytes": 0, "cameras": {}, "disk_usage": {}} + # Get disk usage for base path if os.path.exists(self.storage_config.base_path): disk_usage = shutil.disk_usage(self.storage_config.base_path) - 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 - } - - # Analyze files by camera + 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} + + # Scan actual filesystem for all video files + # This ensures we count all files, not just those in the index + for camera_config in self.config.cameras: + camera_name = camera_config.name + storage_path = Path(camera_config.storage_path) + + 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(): camera_name = file_info["camera_name"] - - 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"): + if camera_name in stats["cameras"] and file_info.get("duration_seconds"): duration = file_info["duration_seconds"] stats["cameras"][camera_name]["total_duration_seconds"] += duration - + return stats - + except Exception as e: self.logger.error(f"Error getting storage statistics: {e}") return {} - + def cleanup_old_files(self, max_age_days: Optional[int] = None) -> Dict[str, Any]: """Clean up old recording files""" if max_age_days is None: max_age_days = self.storage_config.cleanup_older_than_days - + cutoff_date = datetime.now() - timedelta(days=max_age_days) - - cleanup_stats = { - "files_removed": 0, - "bytes_freed": 0, - "errors": [] - } - + + cleanup_stats = {"files_removed": 0, "bytes_freed": 0, "errors": []} + try: files_to_remove = [] - + # Find files older than cutoff date for file_id, file_info in self.file_index["files"].items(): try: @@ -312,81 +307,74 @@ class StorageManager: files_to_remove.append((file_id, file_info)) except Exception as e: cleanup_stats["errors"].append(f"Error parsing date for {file_id}: {e}") - + # Remove old files for file_id, file_info in files_to_remove: try: filename = file_info["filename"] - + # Remove physical file if os.path.exists(filename): file_size = os.path.getsize(filename) os.remove(filename) cleanup_stats["bytes_freed"] += file_size self.logger.info(f"Removed old file: {filename}") - + # Remove from index del self.file_index["files"][file_id] cleanup_stats["files_removed"] += 1 - + except Exception as e: error_msg = f"Error removing file {file_id}: {e}" cleanup_stats["errors"].append(error_msg) self.logger.error(error_msg) - + # Save updated index if cleanup_stats["files_removed"] > 0: self._save_file_index() - - self.logger.info(f"Cleanup completed: {cleanup_stats['files_removed']} files removed, " - f"{cleanup_stats['bytes_freed']} bytes freed") - + + self.logger.info(f"Cleanup completed: {cleanup_stats['files_removed']} files removed, " f"{cleanup_stats['bytes_freed']} bytes freed") + return cleanup_stats - + except Exception as e: self.logger.error(f"Error during cleanup: {e}") cleanup_stats["errors"].append(str(e)) return cleanup_stats - + def get_file_info(self, file_id: str) -> Optional[Dict[str, Any]]: """Get information about a specific file""" return self.file_index["files"].get(file_id) - + def delete_file(self, file_id: str) -> bool: """Delete a specific recording file""" try: if file_id not in self.file_index["files"]: self.logger.warning(f"File ID not found: {file_id}") return False - + file_info = self.file_index["files"][file_id] filename = file_info["filename"] - + # Remove physical file if os.path.exists(filename): os.remove(filename) self.logger.info(f"Deleted file: {filename}") - + # Remove from index del self.file_index["files"][file_id] self._save_file_index() - + return True - + except Exception as e: self.logger.error(f"Error deleting file {file_id}: {e}") return False - + def verify_storage_integrity(self) -> Dict[str, Any]: """Verify storage integrity and fix issues""" - integrity_report = { - "total_files_in_index": len(self.file_index["files"]), - "missing_files": [], - "orphaned_files": [], - "corrupted_entries": [], - "fixed_issues": 0 - } - + integrity_report = {"total_files_in_index": len(self.file_index["files"]), "missing_files": [], "orphaned_files": [], "corrupted_entries": [], "fixed_issues": 0} + try: # Check for missing files (in index but not on disk) for file_id, file_info in list(self.file_index["files"].items()): @@ -396,7 +384,7 @@ class StorageManager: # Remove from index del self.file_index["files"][file_id] integrity_report["fixed_issues"] += 1 - + # Check for orphaned files (on disk but not in index) for camera_config in self.config.cameras: storage_path = Path(camera_config.storage_path) @@ -405,15 +393,15 @@ class StorageManager: file_id = video_file.name if file_id not in self.file_index["files"]: integrity_report["orphaned_files"].append(str(video_file)) - + # Save updated index if fixes were made if integrity_report["fixed_issues"] > 0: self._save_file_index() - + self.logger.info(f"Storage integrity check completed: {integrity_report['fixed_issues']} issues fixed") - + return integrity_report - + except Exception as e: self.logger.error(f"Error during integrity check: {e}") integrity_report["error"] = str(e) diff --git a/uv.lock b/uv.lock index 363539a..6d96c9f 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "certifi" 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" }, ] +[[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]] name = "charset-normalizer" 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" }, ] +[[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]] name = "contourpy" 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" }, ] +[[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]] name = "fastapi" 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" }, ] +[[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]] name = "kiwisolver" 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" }, ] +[[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]] name = "numpy" 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" }, ] +[[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]] name = "pillow" 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" }, ] +[[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]] name = "pydantic" 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" }, ] +[[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]] name = "pyparsing" 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" }, ] +[[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]] name = "requests" 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" }, ] +[[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]] name = "starlette" 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" }, ] +[[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]] name = "tqdm" 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" }, ] +[[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]] name = "typing-extensions" version = "4.14.1" @@ -766,6 +1197,7 @@ source = { virtual = "." } dependencies = [ { name = "fastapi" }, { name = "imageio" }, + { name = "ipykernel" }, { name = "matplotlib" }, { name = "numpy" }, { name = "opencv-python" }, @@ -782,6 +1214,7 @@ dependencies = [ requires-dist = [ { name = "fastapi", specifier = ">=0.104.0" }, { name = "imageio", specifier = ">=2.37.0" }, + { name = "ipykernel", specifier = ">=6.30.0" }, { name = "matplotlib", specifier = ">=3.10.3" }, { name = "numpy", specifier = ">=2.3.2" }, { 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" }, ] +[[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]] name = "websockets" version = "15.0.1"