Update camera management and MQTT logging for improved functionality
- Changed log level in configuration from WARNING to INFO for better visibility of system operations. - Enhanced StandaloneAutoRecorder initialization to accept camera manager, state manager, and event system for improved modularity. - Updated recording routes to handle optional request bodies and improved error logging for better debugging. - Added checks in CameraMonitor to determine if a camera is already in use before initialization, enhancing resource management. - Improved MQTT client logging to provide more detailed connection and message handling information. - Added new MQTT event handling capabilities to the VisionApiClient for better tracking of machine states.
This commit is contained in:
251
SESSION_SUMMARY.md
Normal file
251
SESSION_SUMMARY.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Session Summary: MQTT Auto-Recording Debugging & Theme Implementation
|
||||
|
||||
## Overview
|
||||
This session focused on debugging MQTT-triggered auto-recording functionality and implementing dark/light theme toggle in the management dashboard. Multiple issues were identified and resolved related to camera recording, MQTT message handling, and UI theming.
|
||||
|
||||
---
|
||||
|
||||
## Major Tasks Completed
|
||||
|
||||
### 1. Scheduling Module Extraction (Previous Session)
|
||||
- Extracted the Scheduling module into a separate microfrontend (`scheduling-remote`)
|
||||
- Configured Module Federation for the new microfrontend
|
||||
- Updated `docker-compose.yml` to include the new service
|
||||
|
||||
### 2. Dark/Light Theme Toggle Implementation
|
||||
- Created `useTheme` hook (`management-dashboard-web-app/src/hooks/useTheme.ts`)
|
||||
- Added theme toggle button to `TopNavbar.tsx`
|
||||
- Applied dark mode classes across multiple components:
|
||||
- `DashboardLayout.tsx` - Main content area
|
||||
- `Login.tsx` - Login page background
|
||||
- `DataEntry.tsx` - Widgets, text, borders
|
||||
- `UserManagement.tsx` - Stats cards, tables, forms
|
||||
- `ExperimentPhases.tsx` - Phase cards, badges
|
||||
- `index.css` - Body background
|
||||
- `index.html` - Inline script to prevent FOUC (Flash of Unstyled Content)
|
||||
|
||||
### 3. MQTT Auto-Recording Debugging
|
||||
|
||||
#### Issues Identified:
|
||||
1. **Manual recording buttons not working** - "Record" buttons for cameras couldn't start manual recording
|
||||
2. **Automatic recording unavailable** - MQTT-triggered auto-recording wasn't functioning
|
||||
3. **Camera2 initialization error** - Error code 32774 when initializing camera2
|
||||
4. **Button visibility issues** - "Restart Camera" and "Stop Streaming" buttons appeared transparent
|
||||
|
||||
#### Fixes Applied:
|
||||
|
||||
##### A. Transparent Buttons Fix
|
||||
**File:** `vision-system-remote/src/components/CameraCard.tsx`
|
||||
- Added inline `style` attributes to "Restart Camera" and "Stop Streaming" buttons
|
||||
- Explicitly set `backgroundColor` and `color` to prevent transparency
|
||||
|
||||
##### B. Camera Initialization Error (32774)
|
||||
**File:** `camera-management-api/usda_vision_system/camera/monitor.py`
|
||||
- Enhanced error handling for camera initialization error code 32774
|
||||
- Added logic to check if camera is already in use by existing recorder/streamer
|
||||
- If error 32774 occurs, camera is marked as "available" with warning instead of failing completely
|
||||
- Provides better diagnostics for cameras that might be in use by another process
|
||||
|
||||
##### C. StandaloneAutoRecorder Integration
|
||||
**File:** `camera-management-api/usda_vision_system/recording/standalone_auto_recorder.py`
|
||||
- **Problem:** `StandaloneAutoRecorder` was creating its own MQTT client and camera recorders, causing conflicts
|
||||
- **Solution:**
|
||||
- Modified constructor to accept `camera_manager`, `state_manager`, and `event_system` instances
|
||||
- Removed internal MQTT client setup when using shared instances
|
||||
- Now subscribes to `EventType.MACHINE_STATE_CHANGED` events from the event system
|
||||
- Uses `camera_manager.manual_start_recording()` and `manual_stop_recording()` instead of creating its own recorders
|
||||
- Added comprehensive logging for event reception and recording operations
|
||||
|
||||
**File:** `camera-management-api/usda_vision_system/main.py`
|
||||
- Updated `USDAVisionSystem` constructor to pass `camera_manager`, `state_manager`, and `event_system` to `StandaloneAutoRecorder`
|
||||
|
||||
##### D. Missing Constants Import
|
||||
**File:** `camera-management-api/usda_vision_system/camera/recorder.py`
|
||||
- **Error:** `NameError: name 'CAMERA_TEST_CAPTURE_TIMEOUT' is not defined`
|
||||
- **Fix:** Added missing imports:
|
||||
- `CAMERA_GET_BUFFER_TIMEOUT`
|
||||
- `CAMERA_INIT_TIMEOUT`
|
||||
- `CAMERA_TEST_CAPTURE_TIMEOUT`
|
||||
- `DEFAULT_VIDEO_FPS`
|
||||
- `BRIEF_PAUSE_SLEEP`
|
||||
- All imported from `.constants`
|
||||
|
||||
##### E. Recording Routes Enhancement
|
||||
**File:** `camera-management-api/usda_vision_system/api/routes/recording_routes.py`
|
||||
- Made `StartRecordingRequest` parameter optional for `start_recording` endpoint
|
||||
- Added logic to create default `StartRecordingRequest` if `request` is `None`
|
||||
- Added INFO-level logging for recording attempts and success/failure
|
||||
- Improved error messages for `manual_start_recording` failures
|
||||
|
||||
##### F. MQTT Logging Improvements
|
||||
**Files:**
|
||||
- `camera-management-api/usda_vision_system/mqtt/client.py`
|
||||
- `camera-management-api/usda_vision_system/mqtt/handlers.py`
|
||||
|
||||
**Changes:**
|
||||
- Changed MQTT message logging from `DEBUG` to `INFO` level for visibility in production
|
||||
- Added detailed logging for:
|
||||
- Message reception: `📡 MQTT MESSAGE RECEIVED - Topic: {topic}, Payload: '{payload}'`
|
||||
- Message processing: `📡 Processing MQTT message for machine '{machine_name}': '{payload}'`
|
||||
- Payload normalization: `📡 Normalized payload '{payload}' -> '{normalized_payload}'`
|
||||
- Event publishing: `📡 Publishing MACHINE_STATE_CHANGED event`
|
||||
- Connection status: `🔗 MQTT CONNECTED` with broker details
|
||||
- Subscription confirmation: `📋 MQTT subscribed to {count} topics`
|
||||
|
||||
##### G. MQTT Test Script
|
||||
**File:** `camera-management-api/test_mqtt_simple.py` (NEW)
|
||||
- Created standalone MQTT test script to verify connectivity and message reception
|
||||
- Connects to MQTT broker and subscribes to configured topics
|
||||
- Displays received messages in real-time with timestamps
|
||||
- Validates payload format (on/off/true/false/1/0)
|
||||
- Shows connection statistics
|
||||
|
||||
### 4. Configuration Updates
|
||||
**File:** `camera-management-api/config.json`
|
||||
- Confirmed `log_level` is set to `INFO`
|
||||
- `auto_recording_enabled` is set to `true`
|
||||
|
||||
---
|
||||
|
||||
## Key Files Modified
|
||||
|
||||
### Frontend (React/TypeScript)
|
||||
1. `management-dashboard-web-app/src/hooks/useTheme.ts` - NEW
|
||||
2. `management-dashboard-web-app/src/components/TopNavbar.tsx`
|
||||
3. `management-dashboard-web-app/src/components/DashboardLayout.tsx`
|
||||
4. `management-dashboard-web-app/src/components/Login.tsx`
|
||||
5. `management-dashboard-web-app/src/components/DataEntry.tsx`
|
||||
6. `management-dashboard-web-app/src/components/UserManagement.tsx`
|
||||
7. `management-dashboard-web-app/src/components/ExperimentPhases.tsx`
|
||||
8. `management-dashboard-web-app/src/index.css`
|
||||
9. `management-dashboard-web-app/index.html`
|
||||
10. `vision-system-remote/src/components/CameraCard.tsx`
|
||||
|
||||
### Backend (Python/FastAPI)
|
||||
1. `camera-management-api/usda_vision_system/recording/standalone_auto_recorder.py`
|
||||
2. `camera-management-api/usda_vision_system/main.py`
|
||||
3. `camera-management-api/usda_vision_system/camera/recorder.py`
|
||||
4. `camera-management-api/usda_vision_system/camera/monitor.py`
|
||||
5. `camera-management-api/usda_vision_system/api/routes/recording_routes.py`
|
||||
6. `camera-management-api/usda_vision_system/mqtt/client.py`
|
||||
7. `camera-management-api/usda_vision_system/mqtt/handlers.py`
|
||||
8. `camera-management-api/test_mqtt_simple.py` - NEW
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
### ✅ Completed
|
||||
- Dark/light theme toggle fully implemented
|
||||
- Transparent buttons fixed
|
||||
- Camera initialization error handling improved
|
||||
- `StandaloneAutoRecorder` properly integrated with shared instances
|
||||
- Missing constants imported
|
||||
- Recording routes enhanced with better logging
|
||||
- MQTT logging improved to INFO level
|
||||
- MQTT test script created
|
||||
|
||||
### 🔄 Testing Needed
|
||||
- **MQTT Auto-Recording**: Needs verification that MQTT messages trigger recording
|
||||
- Test script available: `camera-management-api/test_mqtt_simple.py`
|
||||
- Monitor logs for MQTT message flow: `docker compose logs api -f | grep -i "mqtt\|📡"`
|
||||
- Check MQTT status: `curl http://localhost:8000/mqtt/status`
|
||||
- Check recent events: `curl http://localhost:8000/mqtt/events?limit=10`
|
||||
|
||||
### 📋 Expected MQTT Message Flow
|
||||
When a machine turns on/off, the following should appear in logs:
|
||||
1. `📡 MQTT MESSAGE RECEIVED` - Message received from broker
|
||||
2. `📡 Processing MQTT message` - Message being processed
|
||||
3. `📡 Normalized payload` - Payload normalization
|
||||
4. `✅ Published MACHINE_STATE_CHANGED event` - Event published to event system
|
||||
5. `📡 AUTO-RECORDER: Received MACHINE_STATE_CHANGED event` - Auto-recorder received event
|
||||
6. `✅ Started auto-recording` - Recording started
|
||||
|
||||
---
|
||||
|
||||
## MQTT Configuration
|
||||
|
||||
From `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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Machine-to-Camera Mapping (from `standalone_auto_recorder.py`):
|
||||
- `vibratory_conveyor` → `camera2`
|
||||
- `blower_separator` → `camera1`
|
||||
|
||||
---
|
||||
|
||||
## Testing Commands
|
||||
|
||||
### Test MQTT Connectivity
|
||||
```bash
|
||||
# Option 1: Standalone test script
|
||||
docker compose exec api python test_mqtt_simple.py
|
||||
|
||||
# Option 2: Check MQTT status via API
|
||||
curl -s http://localhost:8000/mqtt/status | python -m json.tool
|
||||
|
||||
# Option 3: Monitor API logs for MQTT messages
|
||||
docker compose logs api -f | grep -i "mqtt\|📡"
|
||||
|
||||
# Option 4: Check recent MQTT events
|
||||
curl -s http://localhost:8000/mqtt/events?limit=10 | python -m json.tool
|
||||
```
|
||||
|
||||
### Restart API Container
|
||||
```bash
|
||||
docker compose restart api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Event Flow for Auto-Recording
|
||||
1. **MQTT Message Received** → `MQTTClient._on_message()`
|
||||
2. **Message Processed** → `MQTTMessageHandler.handle_message()`
|
||||
3. **State Updated** → `StateManager.update_machine_state()`
|
||||
4. **Event Published** → `EventSystem.publish(EventType.MACHINE_STATE_CHANGED)`
|
||||
5. **Event Received** → `StandaloneAutoRecorder._on_machine_state_changed()`
|
||||
6. **Recording Started/Stopped** → `CameraManager.manual_start_recording()` / `manual_stop_recording()`
|
||||
|
||||
### Key Components
|
||||
- **MQTTClient**: Connects to MQTT broker, subscribes to topics, receives messages
|
||||
- **MQTTMessageHandler**: Processes MQTT messages, normalizes payloads, updates state
|
||||
- **EventSystem**: Publishes/subscribes to internal events
|
||||
- **StandaloneAutoRecorder**: Subscribes to `MACHINE_STATE_CHANGED` events, triggers recording
|
||||
- **CameraManager**: Manages camera operations including recording
|
||||
|
||||
---
|
||||
|
||||
## Notes for Future Sessions
|
||||
|
||||
1. **MQTT Testing**: If auto-recording still doesn't work, verify:
|
||||
- MQTT broker is reachable from API container
|
||||
- Topics match exactly (case-sensitive)
|
||||
- Messages are being published to correct topics
|
||||
- Payload format is recognized (on/off/true/false/1/0)
|
||||
|
||||
2. **Camera Initialization**: Error 32774 typically means camera is in use. The system now handles this gracefully but may need investigation if cameras are not accessible.
|
||||
|
||||
3. **Theme Persistence**: Theme preference is stored in `localStorage` and persists across sessions.
|
||||
|
||||
4. **Log Level**: Currently set to `INFO` in `config.json` for comprehensive event tracking.
|
||||
|
||||
---
|
||||
|
||||
## Session Date
|
||||
Session completed with focus on MQTT debugging and enhanced logging for troubleshooting auto-recording functionality.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"system": {
|
||||
"camera_check_interval_seconds": 2,
|
||||
"log_level": "WARNING",
|
||||
"log_level": "INFO",
|
||||
"log_file": "usda_vision_system.log",
|
||||
"api_host": "0.0.0.0",
|
||||
"api_port": 8000,
|
||||
|
||||
162
camera-management-api/test_mqtt_simple.py
Executable file
162
camera-management-api/test_mqtt_simple.py
Executable file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple MQTT Test Script
|
||||
|
||||
This script tests MQTT connectivity and message reception.
|
||||
It connects to the broker and listens for messages on the configured topics.
|
||||
|
||||
Usage:
|
||||
python test_mqtt_simple.py
|
||||
"""
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# MQTT Configuration (from config.json)
|
||||
MQTT_BROKER_HOST = "192.168.1.110"
|
||||
MQTT_BROKER_PORT = 1883
|
||||
MQTT_USERNAME = None
|
||||
MQTT_PASSWORD = None
|
||||
|
||||
# Topics to monitor (from config.json)
|
||||
MQTT_TOPICS = {
|
||||
"vibratory_conveyor": "vision/vibratory_conveyor/state",
|
||||
"blower_separator": "vision/blower_separator/state"
|
||||
}
|
||||
|
||||
class SimpleMQTTTester:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.message_count = 0
|
||||
self.running = True
|
||||
|
||||
def on_connect(self, client, userdata, flags, rc):
|
||||
"""Callback when client connects"""
|
||||
if rc == 0:
|
||||
print(f"✅ Connected to MQTT broker: {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}")
|
||||
|
||||
# Subscribe to all topics
|
||||
for machine_name, topic in MQTT_TOPICS.items():
|
||||
result, mid = client.subscribe(topic)
|
||||
if result == mqtt.MQTT_ERR_SUCCESS:
|
||||
print(f"📋 Subscribed to: {topic} (machine: {machine_name})")
|
||||
else:
|
||||
print(f"❌ Failed to subscribe to {topic}: {result}")
|
||||
else:
|
||||
print(f"❌ Connection failed with return code {rc}")
|
||||
|
||||
def on_disconnect(self, client, userdata, rc):
|
||||
"""Callback when client disconnects"""
|
||||
if rc != 0:
|
||||
print(f"⚠️ Unexpected disconnection (rc: {rc})")
|
||||
else:
|
||||
print("🔌 Disconnected from broker")
|
||||
|
||||
def on_message(self, client, userdata, msg):
|
||||
"""Callback when a message is received"""
|
||||
try:
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode("utf-8").strip()
|
||||
timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3]
|
||||
|
||||
self.message_count += 1
|
||||
|
||||
# Find machine name
|
||||
machine_name = "unknown"
|
||||
for name, configured_topic in MQTT_TOPICS.items():
|
||||
if topic == configured_topic:
|
||||
machine_name = name
|
||||
break
|
||||
|
||||
# Display message
|
||||
print(f"\n📡 [{timestamp}] Message #{self.message_count}")
|
||||
print(f" 🏭 Machine: {machine_name}")
|
||||
print(f" 📍 Topic: {topic}")
|
||||
print(f" 📄 Payload: '{payload}'")
|
||||
print(f" 📊 Total messages received: {self.message_count}")
|
||||
|
||||
# Check if payload is valid on/off
|
||||
payload_lower = payload.lower()
|
||||
if payload_lower in ["on", "off", "true", "false", "1", "0"]:
|
||||
state = "ON" if payload_lower in ["on", "true", "1"] else "OFF"
|
||||
print(f" ✅ Valid state: {state}")
|
||||
else:
|
||||
print(f" ⚠️ Unusual payload format: '{payload}'")
|
||||
print("-" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing message: {e}")
|
||||
|
||||
def start(self):
|
||||
"""Start the MQTT tester"""
|
||||
print("🧪 Starting MQTT Test")
|
||||
print("=" * 60)
|
||||
print(f"Broker: {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}")
|
||||
print(f"Topics to monitor: {len(MQTT_TOPICS)}")
|
||||
for name, topic in MQTT_TOPICS.items():
|
||||
print(f" - {name}: {topic}")
|
||||
print("=" * 60)
|
||||
print("\nWaiting for messages... (Press Ctrl+C to stop)\n")
|
||||
|
||||
# Create client
|
||||
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
|
||||
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 MQTT_USERNAME and MQTT_PASSWORD:
|
||||
self.client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
||||
|
||||
# Connect
|
||||
try:
|
||||
self.client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, 60)
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect: {e}")
|
||||
return False
|
||||
|
||||
# Start loop
|
||||
self.client.loop_start()
|
||||
|
||||
# Wait for messages
|
||||
try:
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n🛑 Stopping test...")
|
||||
self.running = False
|
||||
|
||||
# Cleanup
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
|
||||
print(f"\n📊 Test Summary:")
|
||||
print(f" Total messages received: {self.message_count}")
|
||||
print("✅ Test completed")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
tester = SimpleMQTTTester()
|
||||
|
||||
# Setup signal handler for graceful shutdown
|
||||
def signal_handler(sig, frame):
|
||||
print("\n\n🛑 Received interrupt signal, shutting down...")
|
||||
tester.running = False
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
success = tester.start()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Recording-related API routes.
|
||||
|
||||
import logging
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from typing import Optional
|
||||
from ...camera.manager import CameraManager
|
||||
from ..models import StartRecordingResponse, StopRecordingResponse, StartRecordingRequest
|
||||
from ...core.timezone_utils import format_filename_timestamp
|
||||
@@ -17,12 +18,19 @@ def register_recording_routes(
|
||||
"""Register recording-related routes"""
|
||||
|
||||
@app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse)
|
||||
async def start_recording(camera_name: str, request: StartRecordingRequest):
|
||||
async def start_recording(camera_name: str, request: Optional[StartRecordingRequest] = None):
|
||||
"""Manually start recording for a camera"""
|
||||
try:
|
||||
if not camera_manager:
|
||||
logger.error("Camera manager not available")
|
||||
raise HTTPException(status_code=503, detail="Camera manager not available")
|
||||
|
||||
# Handle case where request body might be None or empty
|
||||
if request is None:
|
||||
request = StartRecordingRequest()
|
||||
|
||||
logger.info(f"📹 Starting recording for {camera_name} - filename: {request.filename}, exposure_ms: {request.exposure_ms}, gain: {request.gain}, fps: {request.fps}")
|
||||
|
||||
success = camera_manager.manual_start_recording(
|
||||
camera_name=camera_name,
|
||||
filename=request.filename,
|
||||
@@ -37,19 +45,28 @@ def register_recording_routes(
|
||||
if request.filename:
|
||||
timestamp = format_filename_timestamp()
|
||||
actual_filename = f"{timestamp}_{request.filename}"
|
||||
else:
|
||||
timestamp = format_filename_timestamp()
|
||||
camera_config = camera_manager.get_camera_config(camera_name)
|
||||
video_format = camera_config.video_format if camera_config else "mp4"
|
||||
actual_filename = f"{camera_name}_manual_{timestamp}.{video_format}"
|
||||
|
||||
logger.info(f"✅ Recording started successfully for {camera_name}: {actual_filename}")
|
||||
return StartRecordingResponse(
|
||||
success=True,
|
||||
message=f"Recording started for {camera_name}",
|
||||
filename=actual_filename
|
||||
)
|
||||
else:
|
||||
logger.error(f"❌ Failed to start recording for {camera_name} - manual_start_recording returned False")
|
||||
return StartRecordingResponse(
|
||||
success=False,
|
||||
message=f"Failed to start recording for {camera_name}"
|
||||
message=f"Failed to start recording for {camera_name}. Check camera status and logs."
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting recording: {e}")
|
||||
logger.error(f"❌ Error starting recording for {camera_name}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse)
|
||||
|
||||
@@ -188,6 +188,34 @@ class CameraMonitor:
|
||||
|
||||
self.logger.info(f"Attempting to initialize camera {camera_name} for availability test...")
|
||||
|
||||
# Check if camera is already in use by recorder or streamer before trying to initialize
|
||||
recorder = self.camera_manager.camera_recorders.get(camera_name) if self.camera_manager else None
|
||||
streamer = self.camera_manager.camera_streamers.get(camera_name) if self.camera_manager else None
|
||||
|
||||
camera_in_use = False
|
||||
if recorder and recorder.hCamera:
|
||||
try:
|
||||
# Check if recorder has camera open
|
||||
if mvsdk.CameraIsOpened(recorder.hCamera):
|
||||
camera_in_use = True
|
||||
self.logger.info(f"Camera {camera_name} is already in use by recorder (handle: {recorder.hCamera})")
|
||||
except:
|
||||
pass
|
||||
|
||||
if not camera_in_use and streamer and streamer.hCamera:
|
||||
try:
|
||||
# Check if streamer has camera open
|
||||
if mvsdk.CameraIsOpened(streamer.hCamera):
|
||||
camera_in_use = True
|
||||
self.logger.info(f"Camera {camera_name} is already in use by streamer (handle: {streamer.hCamera})")
|
||||
except:
|
||||
pass
|
||||
|
||||
# If camera is already in use, mark as available (since it's working, just occupied)
|
||||
if camera_in_use:
|
||||
self.logger.info(f"Camera {camera_name} is in use by system components - marking as available")
|
||||
return "available", "Camera is in use by system", self._get_device_info_dict(device_info)
|
||||
|
||||
# Suppress output to avoid MVCAMAPI error messages during camera testing
|
||||
hCamera = None
|
||||
try:
|
||||
@@ -195,7 +223,26 @@ class CameraMonitor:
|
||||
hCamera = mvsdk.CameraInit(device_info, -1, -1)
|
||||
self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...")
|
||||
except mvsdk.CameraException as init_e:
|
||||
self.logger.warning(f"CameraInit failed for {camera_name}: {init_e.message} (error_code: {init_e.error_code})")
|
||||
error_msg = f"CameraInit failed for {camera_name}: {init_e.message} (error_code: {init_e.error_code})"
|
||||
|
||||
# Special handling for error code 32774 (camera already in use)
|
||||
if init_e.error_code == 32774:
|
||||
error_msg += " - Camera may be in use by another process or resource conflict. "
|
||||
error_msg += "This camera may still be functional if accessed through existing recorder/streamer."
|
||||
self.logger.warning(error_msg)
|
||||
# Mark as "available" but with warning, since it might be usable through existing connections
|
||||
# The UI can show a warning but camera operations might still work
|
||||
try:
|
||||
device_info_dict = self._get_device_info_dict(device_info)
|
||||
device_info_dict["init_error"] = "Camera appears in use (error 32774) but may be accessible"
|
||||
device_info_dict["init_error_code"] = 32774
|
||||
except Exception as dev_info_e:
|
||||
self.logger.warning(f"Failed to get device info dict after CameraInit failure: {dev_info_e}")
|
||||
device_info_dict = None
|
||||
return "available", "Camera may be in use (error 32774) - check if recorder/streamer is active", device_info_dict
|
||||
else:
|
||||
self.logger.warning(error_msg)
|
||||
|
||||
# Get device info dict before returning - wrap in try/except in case device_info is corrupted
|
||||
try:
|
||||
device_info_dict = self._get_device_info_dict(device_info)
|
||||
|
||||
@@ -26,6 +26,13 @@ from ..core.events import EventSystem, publish_recording_started, publish_record
|
||||
from ..core.timezone_utils import now_atlanta, format_filename_timestamp
|
||||
from .sdk_config import ensure_sdk_initialized
|
||||
from .utils import suppress_camera_errors
|
||||
from .constants import (
|
||||
CAMERA_GET_BUFFER_TIMEOUT,
|
||||
CAMERA_INIT_TIMEOUT,
|
||||
CAMERA_TEST_CAPTURE_TIMEOUT,
|
||||
DEFAULT_VIDEO_FPS,
|
||||
BRIEF_PAUSE_SLEEP,
|
||||
)
|
||||
|
||||
|
||||
class CameraRecorder:
|
||||
|
||||
@@ -46,7 +46,12 @@ class USDAVisionSystem:
|
||||
self.storage_manager = StorageManager(self.config, self.state_manager, self.event_system)
|
||||
self.mqtt_client = MQTTClient(self.config, self.state_manager, self.event_system)
|
||||
self.camera_manager = CameraManager(self.config, self.state_manager, self.event_system)
|
||||
self.auto_recording_manager = StandaloneAutoRecorder(config=self.config)
|
||||
self.auto_recording_manager = StandaloneAutoRecorder(
|
||||
config=self.config,
|
||||
camera_manager=self.camera_manager,
|
||||
state_manager=self.state_manager,
|
||||
event_system=self.event_system
|
||||
)
|
||||
self.api_server = APIServer(self.config, self.state_manager, self.event_system, self.camera_manager, self.mqtt_client, self.storage_manager, self.auto_recording_manager)
|
||||
|
||||
# System state
|
||||
|
||||
@@ -172,14 +172,15 @@ class MQTTClient:
|
||||
self.connected = True
|
||||
self.state_manager.set_mqtt_connected(True)
|
||||
self.event_system.publish(EventType.MQTT_CONNECTED, "mqtt_client")
|
||||
self.logger.info("🔗 MQTT CONNECTED to broker successfully")
|
||||
self.logger.info(f"🔗 MQTT CONNECTED to broker successfully at {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
|
||||
print(f"🔗 MQTT CONNECTED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
|
||||
|
||||
# Subscribe to topics immediately after connection
|
||||
self._subscribe_to_topics()
|
||||
self.logger.info(f"📋 MQTT subscribed to {len(self.mqtt_config.topics)} topics")
|
||||
else:
|
||||
self.connected = False
|
||||
self.logger.error(f"❌ MQTT CONNECTION FAILED with return code {rc}")
|
||||
self.logger.error(f"❌ MQTT CONNECTION FAILED with return code {rc} to {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
|
||||
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:
|
||||
@@ -201,7 +202,8 @@ class MQTTClient:
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode("utf-8").strip()
|
||||
|
||||
self.logger.debug(f"MQTT message received - Topic: {topic}, Payload: {payload}")
|
||||
# Log at INFO level so we can see messages in production
|
||||
self.logger.info(f"📡 MQTT MESSAGE RECEIVED - Topic: {topic}, Payload: '{payload}'")
|
||||
|
||||
# Update MQTT activity and tracking
|
||||
self.state_manager.update_mqtt_activity()
|
||||
@@ -211,19 +213,20 @@ class MQTTClient:
|
||||
# Get machine name from topic
|
||||
machine_name = self.topic_to_machine.get(topic)
|
||||
if not machine_name:
|
||||
self.logger.warning(f"❓ MQTT UNKNOWN TOPIC: {topic}")
|
||||
print(f"❓ MQTT UNKNOWN TOPIC: {topic}")
|
||||
self.logger.warning(f"❓ MQTT UNKNOWN TOPIC: {topic} (payload: '{payload}')")
|
||||
print(f"❓ MQTT UNKNOWN TOPIC: {topic} (payload: '{payload}')")
|
||||
return
|
||||
|
||||
# Show MQTT message on console
|
||||
# Show MQTT message on console with machine name
|
||||
print(f"📡 MQTT MESSAGE: {machine_name} → {payload}")
|
||||
self.logger.info(f"📡 Processing MQTT message for machine '{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}")
|
||||
self.logger.error(f"❌ Error processing MQTT message: {e}", exc_info=True)
|
||||
|
||||
def publish_message(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
|
||||
"""Publish a message to MQTT broker"""
|
||||
|
||||
@@ -31,10 +31,11 @@ class MQTTMessageHandler:
|
||||
self.message_count += 1
|
||||
self.last_message_time = datetime.now()
|
||||
|
||||
self.logger.info(f"Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: {payload}")
|
||||
self.logger.info(f"📡 Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: '{payload}'")
|
||||
|
||||
# Normalize payload
|
||||
normalized_payload = self._normalize_payload(payload)
|
||||
self.logger.info(f"📡 Normalized payload '{payload}' -> '{normalized_payload}' for machine {machine_name}")
|
||||
|
||||
# Update machine state
|
||||
state_changed = self.state_manager.update_machine_state(name=machine_name, state=normalized_payload, message=payload, topic=topic)
|
||||
@@ -44,9 +45,12 @@ class MQTTMessageHandler:
|
||||
|
||||
# Publish state change event if state actually changed
|
||||
if state_changed:
|
||||
self.logger.info(f"📡 MQTT: Machine {machine_name} state changed to: {normalized_payload}")
|
||||
self.logger.info(f"📡 Publishing MACHINE_STATE_CHANGED event for {machine_name} -> {normalized_payload}")
|
||||
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}")
|
||||
self.logger.info(f"✅ Published MACHINE_STATE_CHANGED event for {machine_name} -> {normalized_payload}")
|
||||
else:
|
||||
self.logger.info(f"📡 Machine {machine_name} state unchanged (still {normalized_payload}) - no event published")
|
||||
|
||||
# Log the message for debugging
|
||||
self._log_message_details(machine_name, topic, payload, normalized_payload)
|
||||
|
||||
@@ -30,13 +30,13 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
from usda_vision_system.core.config import Config
|
||||
from usda_vision_system.camera.recorder import CameraRecorder
|
||||
from usda_vision_system.core.state_manager import StateManager
|
||||
from usda_vision_system.core.events import EventSystem
|
||||
from usda_vision_system.core.events import EventSystem, EventType, Event
|
||||
|
||||
|
||||
class StandaloneAutoRecorder:
|
||||
"""Standalone auto-recording system that monitors MQTT and controls cameras directly"""
|
||||
|
||||
def __init__(self, config_path: str = "config.json", config: Optional[Config] = None):
|
||||
def __init__(self, config_path: str = "config.json", config: Optional[Config] = None, camera_manager=None, state_manager=None, event_system=None):
|
||||
# Load configuration
|
||||
if config:
|
||||
self.config = config
|
||||
@@ -45,9 +45,9 @@ class StandaloneAutoRecorder:
|
||||
|
||||
# Setup logging (only if not already configured)
|
||||
if not logging.getLogger().handlers:
|
||||
# Use WARNING level by default to reduce INFO log noise
|
||||
log_level = getattr(self.config.system, 'log_level', 'WARNING')
|
||||
log_level_num = getattr(logging, log_level.upper(), logging.WARNING)
|
||||
# Use configured log level
|
||||
log_level = getattr(self.config.system, 'log_level', 'INFO')
|
||||
log_level_num = getattr(logging, log_level.upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=log_level_num,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
@@ -59,16 +59,17 @@ class StandaloneAutoRecorder:
|
||||
self.logger = logging.getLogger(__name__)
|
||||
# Ensure this logger respects the configured log level
|
||||
if hasattr(self.config, 'system') and hasattr(self.config.system, 'log_level'):
|
||||
self.logger.setLevel(getattr(logging, self.config.system.log_level.upper(), logging.WARNING))
|
||||
self.logger.setLevel(getattr(logging, self.config.system.log_level.upper(), logging.INFO))
|
||||
|
||||
# Initialize components
|
||||
self.state_manager = StateManager()
|
||||
self.event_system = EventSystem()
|
||||
# Use provided components or create new ones
|
||||
self.state_manager = state_manager if state_manager else StateManager()
|
||||
self.event_system = event_system if event_system else EventSystem()
|
||||
self.camera_manager = camera_manager
|
||||
|
||||
# MQTT client
|
||||
# MQTT client (only if not using event system)
|
||||
self.mqtt_client: Optional[mqtt.Client] = None
|
||||
|
||||
# Camera recorders
|
||||
# Camera recorders (only if not using camera_manager)
|
||||
self.camera_recorders: Dict[str, CameraRecorder] = {}
|
||||
self.active_recordings: Dict[str, str] = {} # camera_name -> filename
|
||||
|
||||
@@ -82,8 +83,17 @@ class StandaloneAutoRecorder:
|
||||
self.running = False
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Subscribe to machine state change events if using event system
|
||||
if self.event_system and self.camera_manager:
|
||||
self.event_system.subscribe(EventType.MACHINE_STATE_CHANGED, self._on_machine_state_changed)
|
||||
self.logger.info("Subscribed to MACHINE_STATE_CHANGED events")
|
||||
|
||||
self.logger.info("Standalone Auto-Recorder initialized")
|
||||
self.logger.info(f"Machine-Camera mapping: {self.machine_camera_map}")
|
||||
if self.camera_manager:
|
||||
self.logger.info("Using provided camera_manager for recording")
|
||||
else:
|
||||
self.logger.info("Will create own camera recorders (standalone mode)")
|
||||
|
||||
def _build_machine_camera_map(self) -> Dict[str, str]:
|
||||
"""Build mapping from machine topics to camera names"""
|
||||
@@ -162,80 +172,137 @@ class StandaloneAutoRecorder:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing MQTT message: {e}")
|
||||
|
||||
def _on_machine_state_changed(self, event: Event):
|
||||
"""Handle machine state change event from event system"""
|
||||
try:
|
||||
machine_name = event.data.get("machine_name")
|
||||
state = event.data.get("state", "").lower()
|
||||
source = event.source
|
||||
|
||||
self.logger.info(f"📡 AUTO-RECORDER: Received MACHINE_STATE_CHANGED event from {source}")
|
||||
self.logger.info(f"📡 AUTO-RECORDER: Event data - machine_name: {machine_name}, state: {state}")
|
||||
|
||||
if not machine_name or not state:
|
||||
self.logger.warning(f"❌ AUTO-RECORDER: Invalid event data - machine_name: {machine_name}, state: {state}")
|
||||
return
|
||||
|
||||
self._handle_machine_state_change(machine_name, state)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"❌ AUTO-RECORDER: Error handling machine state change event: {e}", exc_info=True)
|
||||
|
||||
def _handle_machine_state_change(self, machine_name: str, state: str):
|
||||
"""Handle machine state change"""
|
||||
try:
|
||||
# Check if we have a camera for this machine
|
||||
camera_name = self.machine_camera_map.get(machine_name)
|
||||
if not camera_name:
|
||||
self.logger.debug(f"No camera mapped to machine: {machine_name}")
|
||||
return
|
||||
|
||||
self.logger.info(f"Handling state change: {machine_name} ({camera_name}) -> {state}")
|
||||
self.logger.info(f"📡 MQTT: Machine {machine_name} ({camera_name}) -> {state}")
|
||||
|
||||
if state == "on":
|
||||
self._start_recording(camera_name, machine_name)
|
||||
elif state == "off":
|
||||
self._stop_recording(camera_name, machine_name)
|
||||
else:
|
||||
self.logger.debug(f"Ignoring state '{state}' for machine {machine_name}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling machine state change: {e}")
|
||||
self.logger.error(f"Error handling machine state change: {e}", exc_info=True)
|
||||
|
||||
def _start_recording(self, camera_name: str, machine_name: str):
|
||||
"""Start recording for a camera"""
|
||||
try:
|
||||
# Check if already recording
|
||||
if camera_name in self.active_recordings:
|
||||
self.logger.warning(f"Camera {camera_name} is already recording")
|
||||
camera_info = self.state_manager.get_camera_status(camera_name) if self.state_manager else None
|
||||
if camera_info and camera_info.is_recording:
|
||||
self.logger.info(f"Camera {camera_name} is already recording, skipping")
|
||||
return
|
||||
|
||||
# Get or create camera recorder
|
||||
recorder = self._get_camera_recorder(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Failed to get recorder for camera {camera_name}")
|
||||
return
|
||||
# Use camera_manager if available, otherwise use standalone recorder
|
||||
if self.camera_manager:
|
||||
# Generate filename with timestamp and machine info
|
||||
from ..core.timezone_utils import format_filename_timestamp
|
||||
timestamp = format_filename_timestamp()
|
||||
camera_config = self.config.get_camera_by_name(camera_name)
|
||||
video_format = camera_config.video_format if camera_config else "mp4"
|
||||
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{video_format}"
|
||||
|
||||
# Generate filename with timestamp and machine info
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
camera_config = self.config.get_camera_by_name(camera_name)
|
||||
video_format = camera_config.video_format if camera_config else "mp4"
|
||||
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{video_format}"
|
||||
# Use camera manager to start recording with camera's default settings
|
||||
success = self.camera_manager.manual_start_recording(
|
||||
camera_name=camera_name,
|
||||
filename=filename,
|
||||
exposure_ms=camera_config.exposure_ms if camera_config else None,
|
||||
gain=camera_config.gain if camera_config else None,
|
||||
fps=camera_config.target_fps if camera_config else None
|
||||
)
|
||||
|
||||
# Start recording
|
||||
success = recorder.start_recording(filename)
|
||||
if success:
|
||||
self.active_recordings[camera_name] = filename
|
||||
self.logger.info(f"✅ Started recording: {camera_name} -> {filename}")
|
||||
if success:
|
||||
self.logger.info(f"✅ Started auto-recording: {camera_name} -> {filename}")
|
||||
self.active_recordings[camera_name] = filename
|
||||
else:
|
||||
self.logger.error(f"❌ Failed to start auto-recording for camera {camera_name}")
|
||||
else:
|
||||
self.logger.error(f"❌ Failed to start recording for camera {camera_name}")
|
||||
# Standalone mode - use own recorder
|
||||
recorder = self._get_camera_recorder(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Failed to get recorder for camera {camera_name}")
|
||||
return
|
||||
|
||||
# Generate filename with timestamp and machine info
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
camera_config = self.config.get_camera_by_name(camera_name)
|
||||
video_format = camera_config.video_format if camera_config else "mp4"
|
||||
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{video_format}"
|
||||
|
||||
# Start recording
|
||||
success = recorder.start_recording(filename)
|
||||
if success:
|
||||
self.active_recordings[camera_name] = filename
|
||||
self.logger.info(f"✅ Started recording: {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}")
|
||||
self.logger.error(f"Error starting recording for {camera_name}: {e}", exc_info=True)
|
||||
|
||||
def _stop_recording(self, camera_name: str, machine_name: str):
|
||||
"""Stop recording for a camera"""
|
||||
try:
|
||||
# Check if recording
|
||||
if camera_name not in self.active_recordings:
|
||||
self.logger.warning(f"Camera {camera_name} is not recording")
|
||||
return
|
||||
|
||||
# Get recorder
|
||||
recorder = self._get_camera_recorder(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Failed to get recorder for camera {camera_name}")
|
||||
return
|
||||
|
||||
# Stop recording
|
||||
filename = self.active_recordings.pop(camera_name)
|
||||
success = recorder.stop_recording()
|
||||
|
||||
if success:
|
||||
self.logger.info(f"✅ Stopped recording: {camera_name} -> {filename}")
|
||||
# Use camera_manager if available
|
||||
if self.camera_manager:
|
||||
success = self.camera_manager.manual_stop_recording(camera_name)
|
||||
if success:
|
||||
self.logger.info(f"✅ Stopped auto-recording: {camera_name}")
|
||||
if camera_name in self.active_recordings:
|
||||
filename = self.active_recordings.pop(camera_name)
|
||||
self.logger.debug(f"Recording filename was: {filename}")
|
||||
else:
|
||||
self.logger.warning(f"Camera {camera_name} may not have been recording")
|
||||
else:
|
||||
self.logger.error(f"❌ Failed to stop recording for camera {camera_name}")
|
||||
# Standalone mode - use own recorder
|
||||
if camera_name not in self.active_recordings:
|
||||
self.logger.warning(f"Camera {camera_name} is not recording")
|
||||
return
|
||||
|
||||
recorder = self._get_camera_recorder(camera_name)
|
||||
if not recorder:
|
||||
self.logger.error(f"Failed to get recorder for camera {camera_name}")
|
||||
return
|
||||
|
||||
# Stop recording
|
||||
filename = self.active_recordings.pop(camera_name)
|
||||
success = recorder.stop_recording()
|
||||
|
||||
if success:
|
||||
self.logger.info(f"✅ Stopped recording: {camera_name} -> {filename}")
|
||||
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}")
|
||||
self.logger.error(f"Error stopping recording for {camera_name}: {e}", exc_info=True)
|
||||
|
||||
def _get_camera_recorder(self, camera_name: str) -> Optional[CameraRecorder]:
|
||||
"""Get or create camera recorder"""
|
||||
@@ -356,19 +423,27 @@ class StandaloneAutoRecorder:
|
||||
try:
|
||||
self.logger.info("Starting Standalone Auto-Recorder...")
|
||||
|
||||
# Setup MQTT
|
||||
# If using event system and camera_manager, we don't need our own MQTT client
|
||||
if self.event_system and self.camera_manager:
|
||||
self.logger.info("Using event system - no need for separate MQTT client")
|
||||
self.running = True
|
||||
self.logger.info("✅ Standalone Auto-Recorder started successfully (event-based mode)")
|
||||
return True
|
||||
|
||||
# Otherwise, setup MQTT client for standalone mode
|
||||
if not self._setup_mqtt():
|
||||
self.logger.error("Failed to setup MQTT client")
|
||||
return False
|
||||
|
||||
# Wait for MQTT connection
|
||||
time.sleep(2)
|
||||
|
||||
self.running = True
|
||||
self.logger.info("✅ Standalone Auto-Recorder started successfully")
|
||||
self.logger.info("✅ Standalone Auto-Recorder started successfully (standalone MQTT mode)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start auto-recorder: {e}")
|
||||
self.logger.error(f"Failed to start auto-recorder: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def stop(self) -> bool:
|
||||
|
||||
@@ -995,8 +995,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="p-6 flex flex-col" style={{ height: 'calc(100vh - 48px)' }}>
|
||||
<div className="mb-6 flex-shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-4"
|
||||
@@ -1014,7 +1014,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 flex flex-col flex-1 min-h-0">
|
||||
{error && (
|
||||
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
|
||||
)}
|
||||
@@ -1029,7 +1029,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
|
||||
{/* Left: Conductors with future availability */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -1250,7 +1250,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
</div>
|
||||
)}
|
||||
{/* Week Calendar for selected conductors' availability */}
|
||||
<div className="mt-6">
|
||||
<div className="mt-6 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
||||
<div className="flex gap-2">
|
||||
@@ -1293,7 +1293,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={calendarRef} className="h-[80vh] border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
|
||||
<div ref={calendarRef} className="flex-1 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<DnDCalendar
|
||||
localizer={localizer}
|
||||
|
||||
129
scripts/dark-mode-regex-patterns.md
Normal file
129
scripts/dark-mode-regex-patterns.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Dark Mode Regex Patterns for Find & Replace
|
||||
|
||||
Use these regex patterns in VS Code (Ctrl+Shift+F) to find classes that need dark mode variants.
|
||||
|
||||
## Enable Regex Mode
|
||||
Make sure the `.*` button is enabled in the Find dialog (regex mode).
|
||||
|
||||
## Patterns to Find Classes Without Dark Variants
|
||||
|
||||
### 1. Find `bg-white` without `dark:bg` in the same className
|
||||
```regex
|
||||
className="[^"]*\bbg-white\b(?!.*dark:bg)[^"]*"
|
||||
```
|
||||
|
||||
**Simpler version** (finds bg-white in any context, you manually check):
|
||||
```regex
|
||||
\bbg-white\b
|
||||
```
|
||||
|
||||
### 2. Find `text-gray-900` without `dark:text` in the same className
|
||||
```regex
|
||||
className="[^"]*\btext-gray-900\b(?!.*dark:text)[^"]*"
|
||||
```
|
||||
|
||||
**Simpler version**:
|
||||
```regex
|
||||
\btext-gray-900\b
|
||||
```
|
||||
|
||||
### 3. Find `text-gray-600` or `text-gray-700` without dark variants
|
||||
```regex
|
||||
className="[^"]*\btext-gray-[67]00\b(?!.*dark:text)[^"]*"
|
||||
```
|
||||
|
||||
**Simpler version**:
|
||||
```regex
|
||||
\btext-gray-[67]00\b
|
||||
```
|
||||
|
||||
### 4. Find `text-gray-500` without dark variant
|
||||
```regex
|
||||
\btext-gray-500\b
|
||||
```
|
||||
|
||||
### 5. Find `border-gray-200` without dark variant
|
||||
```regex
|
||||
\bborder-gray-200\b
|
||||
```
|
||||
|
||||
### 6. Find any `bg-gray-*` without dark variant (like bg-gray-50)
|
||||
```regex
|
||||
\bbg-gray-\d+\b
|
||||
```
|
||||
|
||||
## Recommended Manual Process
|
||||
|
||||
Since the negative lookahead patterns are complex, here's a simpler workflow:
|
||||
|
||||
1. **Find all instances of a pattern:**
|
||||
```
|
||||
\bbg-white\b
|
||||
```
|
||||
|
||||
2. **For each match, check if the same line/className contains `dark:bg-`**
|
||||
- If NO → needs dark mode variant
|
||||
- If YES → skip (already has dark mode)
|
||||
|
||||
3. **Replace patterns:**
|
||||
|
||||
### Replacement Patterns
|
||||
|
||||
**Backgrounds:**
|
||||
- Find: `\bbg-white\b`
|
||||
- Replace: `bg-white dark:bg-gray-800`
|
||||
- Then manually add border if missing: `border border-gray-200 dark:border-gray-700`
|
||||
|
||||
**Text colors:**
|
||||
- Find: `\btext-gray-900\b`
|
||||
- Replace: `text-gray-900 dark:text-white`
|
||||
|
||||
- Find: `\btext-gray-800\b`
|
||||
- Replace: `text-gray-800 dark:text-white/90`
|
||||
|
||||
- Find: `\btext-gray-700\b`
|
||||
- Replace: `text-gray-700 dark:text-gray-300`
|
||||
|
||||
- Find: `\btext-gray-600\b`
|
||||
- Replace: `text-gray-600 dark:text-gray-400`
|
||||
|
||||
- Find: `\btext-gray-500\b`
|
||||
- Replace: `text-gray-500 dark:text-gray-400`
|
||||
|
||||
**Borders:**
|
||||
- Find: `\bborder-gray-200\b`
|
||||
- Replace: `border-gray-200 dark:border-gray-700`
|
||||
|
||||
- Find: `\bborder-gray-300\b`
|
||||
- Replace: `border-gray-300 dark:border-gray-600`
|
||||
|
||||
**Table backgrounds:**
|
||||
- Find: `\bbg-gray-50\b`
|
||||
- Replace: `bg-gray-50 dark:bg-gray-900`
|
||||
|
||||
**Badges:**
|
||||
- Find: `\bbg-blue-100\b`
|
||||
- Replace: `bg-blue-100 dark:bg-blue-900/30`
|
||||
|
||||
- Find: `\btext-blue-800\b`
|
||||
- Replace: `text-blue-800 dark:text-blue-300`
|
||||
|
||||
(Similar pattern for green, red, yellow, purple badges)
|
||||
|
||||
## Quick Find Patterns (Check Manually)
|
||||
|
||||
1. **All bg-white**: `\bbg-white\b`
|
||||
2. **All text-gray-***: `\btext-gray-[0-9]+\b`
|
||||
3. **All border-gray-***: `\bborder-gray-[0-9]+\b`
|
||||
4. **All bg-gray-***: `\bbg-gray-[0-9]+\b`
|
||||
|
||||
## Example: Finding bg-white in className strings
|
||||
|
||||
If you want to be more precise and find `bg-white` that appears in a className attribute but doesn't have `dark:bg` nearby:
|
||||
|
||||
```regex
|
||||
className="[^"]*\bbg-white\b[^"]*"(?!.*dark:bg)
|
||||
```
|
||||
|
||||
But this won't work well if dark:bg is on a different line. So the simpler approach of finding all instances and manually checking is recommended.
|
||||
|
||||
237
vision-system-remote/package-lock.json
generated
237
vision-system-remote/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "vision-system-remote",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
@@ -748,6 +749,79 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
|
||||
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz",
|
||||
"integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.20.2",
|
||||
"@react-aria/interactions": "^3.25.0",
|
||||
"@tanstack/react-virtual": "^3.13.9",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -813,6 +887,103 @@
|
||||
"pnpm": ">=7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz",
|
||||
"integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.25.6",
|
||||
"@react-aria/utils": "^3.31.0",
|
||||
"@react-types/shared": "^3.32.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz",
|
||||
"integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.31.0",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-types/shared": "^3.32.1",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz",
|
||||
"integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-stately/utils": "^3.10.8",
|
||||
"@react-types/shared": "^3.32.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.10.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
|
||||
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.32.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz",
|
||||
"integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1128,6 +1299,15 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||
@@ -1410,6 +1590,33 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1896,6 +2103,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3686,6 +3902,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz",
|
||||
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
|
||||
@@ -3724,6 +3946,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
@@ -3822,6 +4050,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3002 --cors -c-1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
|
||||
@@ -197,8 +197,9 @@ export const CameraCard: React.FC<CameraCardProps> = ({
|
||||
onClick={() => onStopStreaming(cameraName)}
|
||||
className="flex items-center justify-center px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
|
||||
title="Stop streaming"
|
||||
style={{ backgroundColor: '#ea580c', color: '#ffffff' }}
|
||||
>
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" style={{ color: '#ffffff' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -211,9 +212,10 @@ export const CameraCard: React.FC<CameraCardProps> = ({
|
||||
<button
|
||||
onClick={() => onRestart(cameraName)}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
|
||||
style={{ backgroundColor: '#ea580c', color: '#ffffff' }}
|
||||
>
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<span className="flex items-center justify-center" style={{ color: '#ffffff' }}>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: '#ffffff' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Restart Camera
|
||||
|
||||
@@ -85,6 +85,21 @@ export interface MqttStatus {
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface MqttEvent {
|
||||
machine_name: string
|
||||
topic: string
|
||||
payload: string
|
||||
normalized_state: string
|
||||
timestamp: string
|
||||
message_number: number
|
||||
}
|
||||
|
||||
export interface MqttEventsResponse {
|
||||
events: MqttEvent[]
|
||||
total_events: number
|
||||
last_updated: string | null
|
||||
}
|
||||
|
||||
export interface StartRecordingResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
@@ -220,6 +235,10 @@ class VisionApiClient {
|
||||
return this.request('/mqtt/status')
|
||||
}
|
||||
|
||||
async getMqttEvents(limit: number = 50): Promise<MqttEventsResponse> {
|
||||
return this.request(`/mqtt/events?limit=${limit}`)
|
||||
}
|
||||
|
||||
async startRecording(cameraName: string, filename?: string): Promise<StartRecordingResponse> {
|
||||
return this.request(`/cameras/${cameraName}/start-recording`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,25 +1,57 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { StatusWidget } from './StatusWidget'
|
||||
import type { SystemStatus } from '../services/api'
|
||||
import { visionApi, type SystemStatus, type MqttEvent } from '../services/api'
|
||||
|
||||
interface MqttStatusWidgetProps {
|
||||
systemStatus: SystemStatus | null
|
||||
}
|
||||
|
||||
export const MqttStatusWidget: React.FC<MqttStatusWidgetProps> = ({ systemStatus }) => {
|
||||
const [lastEvent, setLastEvent] = useState<MqttEvent | null>(null)
|
||||
const isConnected = systemStatus?.mqtt_connected ?? false
|
||||
const lastMessage = systemStatus?.last_mqtt_message
|
||||
|
||||
// Fetch the last MQTT event
|
||||
useEffect(() => {
|
||||
const fetchLastEvent = async () => {
|
||||
try {
|
||||
const eventsData = await visionApi.getMqttEvents(1).catch(() => null)
|
||||
if (eventsData && eventsData.events.length > 0) {
|
||||
setLastEvent(eventsData.events[0])
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - don't clutter console
|
||||
}
|
||||
}
|
||||
|
||||
fetchLastEvent()
|
||||
// Refresh every 5 seconds
|
||||
const interval = setInterval(fetchLastEvent, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const formatLastEvent = () => {
|
||||
if (!lastEvent) return null
|
||||
|
||||
const time = new Date(lastEvent.timestamp).toLocaleTimeString()
|
||||
return `${lastEvent.machine_name}: ${lastEvent.normalized_state.toUpperCase()} (${time})`
|
||||
}
|
||||
|
||||
const subtitle = lastEvent
|
||||
? formatLastEvent()
|
||||
: lastMessage
|
||||
? `Last: ${new Date(lastMessage).toLocaleTimeString()}`
|
||||
: 'No messages'
|
||||
|
||||
return (
|
||||
<StatusWidget
|
||||
title="MQTT Status"
|
||||
status={isConnected}
|
||||
statusText={isConnected ? 'Connected' : 'Disconnected'}
|
||||
subtitle={lastMessage ? `Last: ${new Date(lastMessage).toLocaleTimeString()}` : 'No messages'}
|
||||
subtitle={subtitle || undefined}
|
||||
icon={
|
||||
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user