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:
salirezav
2025-11-03 16:56:53 -05:00
parent 868aa3f036
commit 4acad772f9
17 changed files with 1074 additions and 83 deletions

251
SESSION_SUMMARY.md Normal file
View 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.

View File

@@ -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,

View 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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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"""

View File

@@ -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)

View File

@@ -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:

View File

@@ -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}

View 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.

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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

View File

@@ -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',

View File

@@ -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'}`} />
}
/>
)
}