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": {
|
"system": {
|
||||||
"camera_check_interval_seconds": 2,
|
"camera_check_interval_seconds": 2,
|
||||||
"log_level": "WARNING",
|
"log_level": "INFO",
|
||||||
"log_file": "usda_vision_system.log",
|
"log_file": "usda_vision_system.log",
|
||||||
"api_host": "0.0.0.0",
|
"api_host": "0.0.0.0",
|
||||||
"api_port": 8000,
|
"api_port": 8000,
|
||||||
|
|||||||
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
|
import logging
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from typing import Optional
|
||||||
from ...camera.manager import CameraManager
|
from ...camera.manager import CameraManager
|
||||||
from ..models import StartRecordingResponse, StopRecordingResponse, StartRecordingRequest
|
from ..models import StartRecordingResponse, StopRecordingResponse, StartRecordingRequest
|
||||||
from ...core.timezone_utils import format_filename_timestamp
|
from ...core.timezone_utils import format_filename_timestamp
|
||||||
@@ -17,12 +18,19 @@ def register_recording_routes(
|
|||||||
"""Register recording-related routes"""
|
"""Register recording-related routes"""
|
||||||
|
|
||||||
@app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse)
|
@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"""
|
"""Manually start recording for a camera"""
|
||||||
try:
|
try:
|
||||||
if not camera_manager:
|
if not camera_manager:
|
||||||
|
logger.error("Camera manager not available")
|
||||||
raise HTTPException(status_code=503, detail="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(
|
success = camera_manager.manual_start_recording(
|
||||||
camera_name=camera_name,
|
camera_name=camera_name,
|
||||||
filename=request.filename,
|
filename=request.filename,
|
||||||
@@ -37,19 +45,28 @@ def register_recording_routes(
|
|||||||
if request.filename:
|
if request.filename:
|
||||||
timestamp = format_filename_timestamp()
|
timestamp = format_filename_timestamp()
|
||||||
actual_filename = f"{timestamp}_{request.filename}"
|
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(
|
return StartRecordingResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Recording started for {camera_name}",
|
message=f"Recording started for {camera_name}",
|
||||||
filename=actual_filename
|
filename=actual_filename
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"❌ Failed to start recording for {camera_name} - manual_start_recording returned False")
|
||||||
return StartRecordingResponse(
|
return StartRecordingResponse(
|
||||||
success=False,
|
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:
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse)
|
@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...")
|
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
|
# Suppress output to avoid MVCAMAPI error messages during camera testing
|
||||||
hCamera = None
|
hCamera = None
|
||||||
try:
|
try:
|
||||||
@@ -195,7 +223,26 @@ class CameraMonitor:
|
|||||||
hCamera = mvsdk.CameraInit(device_info, -1, -1)
|
hCamera = mvsdk.CameraInit(device_info, -1, -1)
|
||||||
self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...")
|
self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...")
|
||||||
except mvsdk.CameraException as init_e:
|
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
|
# Get device info dict before returning - wrap in try/except in case device_info is corrupted
|
||||||
try:
|
try:
|
||||||
device_info_dict = self._get_device_info_dict(device_info)
|
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 ..core.timezone_utils import now_atlanta, format_filename_timestamp
|
||||||
from .sdk_config import ensure_sdk_initialized
|
from .sdk_config import ensure_sdk_initialized
|
||||||
from .utils import suppress_camera_errors
|
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:
|
class CameraRecorder:
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ class USDAVisionSystem:
|
|||||||
self.storage_manager = StorageManager(self.config, self.state_manager, self.event_system)
|
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.mqtt_client = MQTTClient(self.config, self.state_manager, self.event_system)
|
||||||
self.camera_manager = CameraManager(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)
|
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
|
# System state
|
||||||
|
|||||||
@@ -172,14 +172,15 @@ class MQTTClient:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self.state_manager.set_mqtt_connected(True)
|
self.state_manager.set_mqtt_connected(True)
|
||||||
self.event_system.publish(EventType.MQTT_CONNECTED, "mqtt_client")
|
self.event_system.publish(EventType.MQTT_CONNECTED, "mqtt_client")
|
||||||
self.logger.info("🔗 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}")
|
print(f"🔗 MQTT CONNECTED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port}")
|
||||||
|
|
||||||
# Subscribe to topics immediately after connection
|
# Subscribe to topics immediately after connection
|
||||||
self._subscribe_to_topics()
|
self._subscribe_to_topics()
|
||||||
|
self.logger.info(f"📋 MQTT subscribed to {len(self.mqtt_config.topics)} topics")
|
||||||
else:
|
else:
|
||||||
self.connected = False
|
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})")
|
print(f"❌ MQTT CONNECTION FAILED: {self.mqtt_config.broker_host}:{self.mqtt_config.broker_port} (code: {rc})")
|
||||||
|
|
||||||
def _on_disconnect(self, client, userdata, rc) -> None:
|
def _on_disconnect(self, client, userdata, rc) -> None:
|
||||||
@@ -201,7 +202,8 @@ class MQTTClient:
|
|||||||
topic = msg.topic
|
topic = msg.topic
|
||||||
payload = msg.payload.decode("utf-8").strip()
|
payload = msg.payload.decode("utf-8").strip()
|
||||||
|
|
||||||
self.logger.debug(f"MQTT message received - Topic: {topic}, Payload: {payload}")
|
# 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
|
# Update MQTT activity and tracking
|
||||||
self.state_manager.update_mqtt_activity()
|
self.state_manager.update_mqtt_activity()
|
||||||
@@ -211,19 +213,20 @@ class MQTTClient:
|
|||||||
# Get machine name from topic
|
# Get machine name from topic
|
||||||
machine_name = self.topic_to_machine.get(topic)
|
machine_name = self.topic_to_machine.get(topic)
|
||||||
if not machine_name:
|
if not machine_name:
|
||||||
self.logger.warning(f"❓ MQTT UNKNOWN TOPIC: {topic}")
|
self.logger.warning(f"❓ MQTT UNKNOWN TOPIC: {topic} (payload: '{payload}')")
|
||||||
print(f"❓ MQTT UNKNOWN TOPIC: {topic}")
|
print(f"❓ MQTT UNKNOWN TOPIC: {topic} (payload: '{payload}')")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Show MQTT message on console
|
# Show MQTT message on console with machine name
|
||||||
print(f"📡 MQTT MESSAGE: {machine_name} → {payload}")
|
print(f"📡 MQTT MESSAGE: {machine_name} → {payload}")
|
||||||
|
self.logger.info(f"📡 Processing MQTT message for machine '{machine_name}': '{payload}'")
|
||||||
|
|
||||||
# Handle the message
|
# Handle the message
|
||||||
self.message_handler.handle_message(machine_name, topic, payload)
|
self.message_handler.handle_message(machine_name, topic, payload)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error_count += 1
|
self.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:
|
def publish_message(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
|
||||||
"""Publish a message to MQTT broker"""
|
"""Publish a message to MQTT broker"""
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ class MQTTMessageHandler:
|
|||||||
self.message_count += 1
|
self.message_count += 1
|
||||||
self.last_message_time = datetime.now()
|
self.last_message_time = datetime.now()
|
||||||
|
|
||||||
self.logger.info(f"Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: {payload}")
|
self.logger.info(f"📡 Processing MQTT message - Machine: {machine_name}, Topic: {topic}, Payload: '{payload}'")
|
||||||
|
|
||||||
# Normalize payload
|
# Normalize payload
|
||||||
normalized_payload = self._normalize_payload(payload)
|
normalized_payload = self._normalize_payload(payload)
|
||||||
|
self.logger.info(f"📡 Normalized payload '{payload}' -> '{normalized_payload}' for machine {machine_name}")
|
||||||
|
|
||||||
# Update machine state
|
# Update machine state
|
||||||
state_changed = self.state_manager.update_machine_state(name=machine_name, state=normalized_payload, message=payload, topic=topic)
|
state_changed = self.state_manager.update_machine_state(name=machine_name, state=normalized_payload, message=payload, topic=topic)
|
||||||
@@ -44,9 +45,12 @@ class MQTTMessageHandler:
|
|||||||
|
|
||||||
# Publish state change event if state actually changed
|
# Publish state change event if state actually changed
|
||||||
if state_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")
|
publish_machine_state_changed(machine_name=machine_name, state=normalized_payload, source="mqtt_handler")
|
||||||
|
self.logger.info(f"✅ Published MACHINE_STATE_CHANGED event for {machine_name} -> {normalized_payload}")
|
||||||
self.logger.info(f"Machine {machine_name} state changed to: {normalized_payload}")
|
else:
|
||||||
|
self.logger.info(f"📡 Machine {machine_name} state unchanged (still {normalized_payload}) - no event published")
|
||||||
|
|
||||||
# Log the message for debugging
|
# Log the message for debugging
|
||||||
self._log_message_details(machine_name, topic, payload, normalized_payload)
|
self._log_message_details(machine_name, topic, payload, normalized_payload)
|
||||||
|
|||||||
@@ -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.core.config import Config
|
||||||
from usda_vision_system.camera.recorder import CameraRecorder
|
from usda_vision_system.camera.recorder import CameraRecorder
|
||||||
from usda_vision_system.core.state_manager import StateManager
|
from usda_vision_system.core.state_manager import StateManager
|
||||||
from usda_vision_system.core.events import EventSystem
|
from usda_vision_system.core.events import EventSystem, EventType, Event
|
||||||
|
|
||||||
|
|
||||||
class StandaloneAutoRecorder:
|
class StandaloneAutoRecorder:
|
||||||
"""Standalone auto-recording system that monitors MQTT and controls cameras directly"""
|
"""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
|
# Load configuration
|
||||||
if config:
|
if config:
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -45,9 +45,9 @@ class StandaloneAutoRecorder:
|
|||||||
|
|
||||||
# Setup logging (only if not already configured)
|
# Setup logging (only if not already configured)
|
||||||
if not logging.getLogger().handlers:
|
if not logging.getLogger().handlers:
|
||||||
# Use WARNING level by default to reduce INFO log noise
|
# Use configured log level
|
||||||
log_level = getattr(self.config.system, 'log_level', 'WARNING')
|
log_level = getattr(self.config.system, 'log_level', 'INFO')
|
||||||
log_level_num = getattr(logging, log_level.upper(), logging.WARNING)
|
log_level_num = getattr(logging, log_level.upper(), logging.INFO)
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=log_level_num,
|
level=log_level_num,
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
@@ -59,16 +59,17 @@ class StandaloneAutoRecorder:
|
|||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
# Ensure this logger respects the configured log level
|
# Ensure this logger respects the configured log level
|
||||||
if hasattr(self.config, 'system') and hasattr(self.config.system, '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
|
# Use provided components or create new ones
|
||||||
self.state_manager = StateManager()
|
self.state_manager = state_manager if state_manager else StateManager()
|
||||||
self.event_system = EventSystem()
|
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
|
self.mqtt_client: Optional[mqtt.Client] = None
|
||||||
|
|
||||||
# Camera recorders
|
# Camera recorders (only if not using camera_manager)
|
||||||
self.camera_recorders: Dict[str, CameraRecorder] = {}
|
self.camera_recorders: Dict[str, CameraRecorder] = {}
|
||||||
self.active_recordings: Dict[str, str] = {} # camera_name -> filename
|
self.active_recordings: Dict[str, str] = {} # camera_name -> filename
|
||||||
|
|
||||||
@@ -82,8 +83,17 @@ class StandaloneAutoRecorder:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self._stop_event = threading.Event()
|
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("Standalone Auto-Recorder initialized")
|
||||||
self.logger.info(f"Machine-Camera mapping: {self.machine_camera_map}")
|
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]:
|
def _build_machine_camera_map(self) -> Dict[str, str]:
|
||||||
"""Build mapping from machine topics to camera names"""
|
"""Build mapping from machine topics to camera names"""
|
||||||
@@ -162,80 +172,137 @@ class StandaloneAutoRecorder:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error processing MQTT message: {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):
|
def _handle_machine_state_change(self, machine_name: str, state: str):
|
||||||
"""Handle machine state change"""
|
"""Handle machine state change"""
|
||||||
try:
|
try:
|
||||||
# Check if we have a camera for this machine
|
# Check if we have a camera for this machine
|
||||||
camera_name = self.machine_camera_map.get(machine_name)
|
camera_name = self.machine_camera_map.get(machine_name)
|
||||||
if not camera_name:
|
if not camera_name:
|
||||||
|
self.logger.debug(f"No camera mapped to machine: {machine_name}")
|
||||||
return
|
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":
|
if state == "on":
|
||||||
self._start_recording(camera_name, machine_name)
|
self._start_recording(camera_name, machine_name)
|
||||||
elif state == "off":
|
elif state == "off":
|
||||||
self._stop_recording(camera_name, machine_name)
|
self._stop_recording(camera_name, machine_name)
|
||||||
|
else:
|
||||||
|
self.logger.debug(f"Ignoring state '{state}' for machine {machine_name}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error handling machine state change: {e}")
|
self.logger.error(f"Error handling machine state change: {e}", exc_info=True)
|
||||||
|
|
||||||
def _start_recording(self, camera_name: str, machine_name: str):
|
def _start_recording(self, camera_name: str, machine_name: str):
|
||||||
"""Start recording for a camera"""
|
"""Start recording for a camera"""
|
||||||
try:
|
try:
|
||||||
# Check if already recording
|
# Check if already recording
|
||||||
if camera_name in self.active_recordings:
|
camera_info = self.state_manager.get_camera_status(camera_name) if self.state_manager else None
|
||||||
self.logger.warning(f"Camera {camera_name} is already recording")
|
if camera_info and camera_info.is_recording:
|
||||||
|
self.logger.info(f"Camera {camera_name} is already recording, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get or create camera recorder
|
# Use camera_manager if available, otherwise use standalone recorder
|
||||||
recorder = self._get_camera_recorder(camera_name)
|
if self.camera_manager:
|
||||||
if not recorder:
|
# Generate filename with timestamp and machine info
|
||||||
self.logger.error(f"Failed to get recorder for camera {camera_name}")
|
from ..core.timezone_utils import format_filename_timestamp
|
||||||
return
|
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
|
# Use camera manager to start recording with camera's default settings
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
success = self.camera_manager.manual_start_recording(
|
||||||
camera_config = self.config.get_camera_by_name(camera_name)
|
camera_name=camera_name,
|
||||||
video_format = camera_config.video_format if camera_config else "mp4"
|
filename=filename,
|
||||||
filename = f"{camera_name}_auto_{machine_name}_{timestamp}.{video_format}"
|
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
|
if success:
|
||||||
success = recorder.start_recording(filename)
|
self.logger.info(f"✅ Started auto-recording: {camera_name} -> {filename}")
|
||||||
if success:
|
self.active_recordings[camera_name] = filename
|
||||||
self.active_recordings[camera_name] = filename
|
else:
|
||||||
self.logger.info(f"✅ Started recording: {camera_name} -> {filename}")
|
self.logger.error(f"❌ Failed to start auto-recording for camera {camera_name}")
|
||||||
else:
|
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:
|
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):
|
def _stop_recording(self, camera_name: str, machine_name: str):
|
||||||
"""Stop recording for a camera"""
|
"""Stop recording for a camera"""
|
||||||
try:
|
try:
|
||||||
# Check if recording
|
# Use camera_manager if available
|
||||||
if camera_name not in self.active_recordings:
|
if self.camera_manager:
|
||||||
self.logger.warning(f"Camera {camera_name} is not recording")
|
success = self.camera_manager.manual_stop_recording(camera_name)
|
||||||
return
|
if success:
|
||||||
|
self.logger.info(f"✅ Stopped auto-recording: {camera_name}")
|
||||||
# Get recorder
|
if camera_name in self.active_recordings:
|
||||||
recorder = self._get_camera_recorder(camera_name)
|
filename = self.active_recordings.pop(camera_name)
|
||||||
if not recorder:
|
self.logger.debug(f"Recording filename was: {filename}")
|
||||||
self.logger.error(f"Failed to get recorder for camera {camera_name}")
|
else:
|
||||||
return
|
self.logger.warning(f"Camera {camera_name} may not have been recording")
|
||||||
|
|
||||||
# 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:
|
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:
|
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]:
|
def _get_camera_recorder(self, camera_name: str) -> Optional[CameraRecorder]:
|
||||||
"""Get or create camera recorder"""
|
"""Get or create camera recorder"""
|
||||||
@@ -356,19 +423,27 @@ class StandaloneAutoRecorder:
|
|||||||
try:
|
try:
|
||||||
self.logger.info("Starting Standalone Auto-Recorder...")
|
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():
|
if not self._setup_mqtt():
|
||||||
|
self.logger.error("Failed to setup MQTT client")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Wait for MQTT connection
|
# Wait for MQTT connection
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
self.running = True
|
self.running = True
|
||||||
self.logger.info("✅ Standalone Auto-Recorder started successfully")
|
self.logger.info("✅ Standalone Auto-Recorder started successfully (standalone MQTT mode)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
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
|
return False
|
||||||
|
|
||||||
def stop(self) -> bool:
|
def stop(self) -> bool:
|
||||||
|
|||||||
@@ -995,8 +995,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 flex flex-col" style={{ height: 'calc(100vh - 48px)' }}>
|
||||||
<div className="mb-6">
|
<div className="mb-6 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mb-4"
|
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>
|
</p>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
|
<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>
|
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
|
||||||
</div>
|
</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 */}
|
{/* Left: Conductors with future availability */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -1250,7 +1250,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Week Calendar for selected conductors' availability */}
|
{/* 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">
|
<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>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -1293,7 +1293,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<DnDCalendar
|
<DnDCalendar
|
||||||
localizer={localizer}
|
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",
|
"name": "vision-system-remote",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
@@ -748,6 +749,79 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -813,6 +887,103 @@
|
|||||||
"pnpm": ">=7.0.1"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1128,6 +1299,15 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.16",
|
"version": "4.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||||
@@ -1410,6 +1590,33 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1896,6 +2103,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3686,6 +3902,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.16",
|
"version": "4.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
|
||||||
@@ -3724,6 +3946,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/type-fest": {
|
||||||
"version": "2.19.0",
|
"version": "2.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||||
@@ -3822,6 +4050,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"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"
|
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3002 --cors -c-1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -197,8 +197,9 @@ export const CameraCard: React.FC<CameraCardProps> = ({
|
|||||||
onClick={() => onStopStreaming(cameraName)}
|
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"
|
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"
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -211,9 +212,10 @@ export const CameraCard: React.FC<CameraCardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onRestart(cameraName)}
|
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"
|
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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Restart Camera
|
Restart Camera
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ export interface MqttStatus {
|
|||||||
uptime_seconds: number
|
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 {
|
export interface StartRecordingResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
@@ -220,6 +235,10 @@ class VisionApiClient {
|
|||||||
return this.request('/mqtt/status')
|
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> {
|
async startRecording(cameraName: string, filename?: string): Promise<StartRecordingResponse> {
|
||||||
return this.request(`/cameras/${cameraName}/start-recording`, {
|
return this.request(`/cameras/${cameraName}/start-recording`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,25 +1,57 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { StatusWidget } from './StatusWidget'
|
import { StatusWidget } from './StatusWidget'
|
||||||
import type { SystemStatus } from '../services/api'
|
import { visionApi, type SystemStatus, type MqttEvent } from '../services/api'
|
||||||
|
|
||||||
interface MqttStatusWidgetProps {
|
interface MqttStatusWidgetProps {
|
||||||
systemStatus: SystemStatus | null
|
systemStatus: SystemStatus | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MqttStatusWidget: React.FC<MqttStatusWidgetProps> = ({ systemStatus }) => {
|
export const MqttStatusWidget: React.FC<MqttStatusWidgetProps> = ({ systemStatus }) => {
|
||||||
|
const [lastEvent, setLastEvent] = useState<MqttEvent | null>(null)
|
||||||
const isConnected = systemStatus?.mqtt_connected ?? false
|
const isConnected = systemStatus?.mqtt_connected ?? false
|
||||||
const lastMessage = systemStatus?.last_mqtt_message
|
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 (
|
return (
|
||||||
<StatusWidget
|
<StatusWidget
|
||||||
title="MQTT Status"
|
title="MQTT Status"
|
||||||
status={isConnected}
|
status={isConnected}
|
||||||
statusText={isConnected ? 'Connected' : 'Disconnected'}
|
statusText={isConnected ? 'Connected' : 'Disconnected'}
|
||||||
subtitle={lastMessage ? `Last: ${new Date(lastMessage).toLocaleTimeString()}` : 'No messages'}
|
subtitle={subtitle || undefined}
|
||||||
icon={
|
icon={
|
||||||
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
|
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user