feat(streaming): Add live streaming functionality for USDA Vision Camera system
- Introduced non-blocking live preview streaming that operates independently from recording. - Implemented REST API endpoints for starting and stopping streams, and retrieving live streams. - Developed a web interface (`camera_preview.html`) for users to control and view camera streams. - Created TypeScript definitions for API integration in React projects. - Added comprehensive testing script (`test_streaming.py`) to validate API endpoints and concurrent operations. - Updated database migration to fix visibility of experiment repetitions for all authenticated users.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, memo, startTransition } from 'react'
|
||||
import {
|
||||
visionApi,
|
||||
type SystemStatus,
|
||||
@@ -14,6 +14,353 @@ import {
|
||||
formatUptime
|
||||
} from '../lib/visionApi'
|
||||
|
||||
// Memoized components to prevent unnecessary re-renders
|
||||
const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.system_started ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{systemStatus.system_started ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">System Status</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Uptime: {formatUptime(systemStatus.uptime_seconds)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.mqtt_connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">MQTT Status</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Last message: {systemStatus.last_mqtt_message || 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{systemStatus.active_recordings} Active
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">Recordings</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Total: {systemStatus.total_recordings}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{Object.keys(systemStatus.cameras).length} Cameras
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">Devices</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
{Object.keys(systemStatus.machines).length} Machines
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
const StorageOverview = memo(({ storageStats }: { storageStats: StorageStats }) => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Storage</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Storage usage and file statistics
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{storageStats.total_files}</div>
|
||||
<div className="text-sm text-gray-500">Total Files</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{formatBytes(storageStats.total_size_bytes)}</div>
|
||||
<div className="text-sm text-gray-500">Total Size</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{formatBytes(storageStats.disk_usage.free)}</div>
|
||||
<div className="text-sm text-gray-500">Free Space</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Usage Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Disk Usage</span>
|
||||
<span>{Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(storageStats.disk_usage.used / storageStats.disk_usage.total) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>{formatBytes(storageStats.disk_usage.used)} used</span>
|
||||
<span>{formatBytes(storageStats.disk_usage.total)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-Camera Statistics */}
|
||||
{Object.keys(storageStats.cameras).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Files by Camera</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(storageStats.cameras).map(([cameraName, stats]) => (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">{cameraName}</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Files:</span>
|
||||
<span className="text-gray-900">{stats.file_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Size:</span>
|
||||
<span className="text-gray-900">{formatBytes(stats.total_size_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
const CamerasStatus = memo(({ systemStatus }: { systemStatus: SystemStatus }) => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Cameras</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Current status of all cameras in the system
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-6">
|
||||
{Object.entries(systemStatus.cameras).map(([cameraName, camera]) => {
|
||||
const friendlyName = camera.device_info?.friendly_name
|
||||
const hasDeviceInfo = !!camera.device_info
|
||||
const hasSerial = !!camera.device_info?.serial_number
|
||||
|
||||
// Determine if camera is connected based on status
|
||||
const isConnected = camera.status === 'available' || camera.status === 'connected'
|
||||
const hasError = camera.status === 'error'
|
||||
const statusText = camera.status || 'unknown'
|
||||
|
||||
return (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{friendlyName || cameraName}
|
||||
{friendlyName && (
|
||||
<span className="text-gray-500 text-sm font-normal ml-2">({cameraName})</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isConnected ? 'bg-green-100 text-green-800' :
|
||||
hasError ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{isConnected ? 'Connected' : hasError ? 'Error' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={`font-medium ${isConnected ? 'text-green-600' :
|
||||
hasError ? 'text-yellow-600' :
|
||||
'text-red-600'
|
||||
}`}>
|
||||
{statusText.charAt(0).toUpperCase() + statusText.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{camera.is_recording && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording:</span>
|
||||
<span className="text-red-600 font-medium flex items-center">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-2 animate-pulse"></div>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDeviceInfo && (
|
||||
<>
|
||||
{camera.device_info.model && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Model:</span>
|
||||
<span className="text-gray-900">{camera.device_info.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasSerial && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Serial:</span>
|
||||
<span className="text-gray-900 font-mono text-xs">{camera.device_info.serial_number}</span>
|
||||
</div>
|
||||
)}
|
||||
{camera.device_info.firmware_version && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Firmware:</span>
|
||||
<span className="text-gray-900 font-mono text-xs">{camera.device_info.firmware_version}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{camera.last_frame_time && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last Frame:</span>
|
||||
<span className="text-gray-900">{new Date(camera.last_frame_time).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.frame_rate && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Frame Rate:</span>
|
||||
<span className="text-gray-900">{camera.frame_rate.toFixed(1)} fps</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.last_checked && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last Checked:</span>
|
||||
<span className="text-gray-900">{new Date(camera.last_checked).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.current_recording_file && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording File:</span>
|
||||
<span className="text-gray-900 truncate ml-2">{camera.current_recording_file}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.last_error && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded">
|
||||
<div className="text-red-800 text-xs">
|
||||
<strong>Error:</strong> {camera.last_error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
const RecentRecordings = memo(({ recordings, systemStatus }: { recordings: Record<string, RecordingInfo>, systemStatus: SystemStatus | null }) => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Recent Recordings</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Latest recording sessions
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Camera
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Filename
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Started
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => {
|
||||
const camera = systemStatus?.cameras[recording.camera_name]
|
||||
const displayName = camera?.device_info?.friendly_name || recording.camera_name
|
||||
|
||||
return (
|
||||
<tr key={recordingId}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{displayName}
|
||||
{camera?.device_info?.friendly_name && (
|
||||
<div className="text-xs text-gray-500">({recording.camera_name})</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{recording.filename}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${recording.status === 'recording' ? 'bg-red-100 text-red-800' :
|
||||
recording.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{recording.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(recording.start_time).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
export function VisionSystem() {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
|
||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
|
||||
@@ -110,38 +457,25 @@ export function VisionSystem() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only update state if data has actually changed to prevent unnecessary re-renders
|
||||
setSystemStatus(prevStatus => {
|
||||
if (JSON.stringify(prevStatus) !== JSON.stringify(statusData)) {
|
||||
return statusData
|
||||
// Batch state updates to minimize re-renders using startTransition for non-urgent updates
|
||||
const updateTime = new Date()
|
||||
|
||||
// Use startTransition for non-urgent state updates to keep the UI responsive
|
||||
startTransition(() => {
|
||||
setSystemStatus(statusData)
|
||||
setStorageStats(storageData)
|
||||
setRecordings(recordingsData)
|
||||
setLastUpdateTime(updateTime)
|
||||
|
||||
// Update MQTT status and events
|
||||
if (mqttStatusData) {
|
||||
setMqttStatus(mqttStatusData)
|
||||
}
|
||||
return prevStatus
|
||||
})
|
||||
|
||||
setStorageStats(prevStats => {
|
||||
if (JSON.stringify(prevStats) !== JSON.stringify(storageData)) {
|
||||
return storageData
|
||||
if (mqttEventsData && mqttEventsData.events) {
|
||||
setMqttEvents(mqttEventsData.events)
|
||||
}
|
||||
return prevStats
|
||||
})
|
||||
|
||||
setRecordings(prevRecordings => {
|
||||
if (JSON.stringify(prevRecordings) !== JSON.stringify(recordingsData)) {
|
||||
return recordingsData
|
||||
}
|
||||
return prevRecordings
|
||||
})
|
||||
|
||||
setLastUpdateTime(new Date())
|
||||
|
||||
// Update MQTT status and events
|
||||
if (mqttStatusData) {
|
||||
setMqttStatus(mqttStatusData)
|
||||
}
|
||||
|
||||
if (mqttEventsData && mqttEventsData.events) {
|
||||
setMqttEvents(mqttEventsData.events)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch vision system data')
|
||||
console.error('Vision system fetch error:', err)
|
||||
@@ -242,8 +576,14 @@ export function VisionSystem() {
|
||||
<h1 className="text-3xl font-bold text-gray-900">Vision System</h1>
|
||||
<p className="mt-2 text-gray-600">Monitor cameras, machines, and recording status</p>
|
||||
{lastUpdateTime && (
|
||||
<p className="mt-1 text-sm text-gray-500 flex items-center space-x-2">
|
||||
<p className={`mt-1 text-sm text-gray-500 flex items-center space-x-2 ${refreshing ? 'animate-pulse' : ''}`}>
|
||||
<span>Last updated: {lastUpdateTime.toLocaleTimeString()}</span>
|
||||
{refreshing && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span className="animate-spin rounded-full h-3 w-3 border-b border-blue-600 mr-1 inline-block"></span>
|
||||
Updating...
|
||||
</span>
|
||||
)}
|
||||
{autoRefreshEnabled && !refreshing && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Auto-refresh: {refreshInterval / 1000}s
|
||||
@@ -296,227 +636,12 @@ export function VisionSystem() {
|
||||
</div>
|
||||
|
||||
{/* System Overview */}
|
||||
{systemStatus && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.system_started ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{systemStatus.system_started ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">System Status</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Uptime: {formatUptime(systemStatus.uptime_seconds)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.mqtt_connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
{systemStatus.mqtt_connected && (
|
||||
<div className="ml-3 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-green-600">Live</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mqttStatus && (
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
<div>{mqttStatus.message_count} messages</div>
|
||||
<div>{mqttStatus.error_count} errors</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">MQTT</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
{mqttStatus ? (
|
||||
<div>
|
||||
<div>Broker: {mqttStatus.broker_host}:{mqttStatus.broker_port}</div>
|
||||
<div>Last message: {new Date(mqttStatus.last_message_time).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>Last message: {new Date(systemStatus.last_mqtt_message).toLocaleTimeString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MQTT Events History */}
|
||||
{mqttEvents.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">Recent Events</h4>
|
||||
<span className="text-xs text-gray-500">{mqttEvents.length} events</span>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-2">
|
||||
{mqttEvents.map((event, index) => (
|
||||
<div key={`${event.timestamp}-${event.message_number}`} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<span className="text-gray-500 font-mono w-12 flex-shrink-0">
|
||||
{new Date(event.timestamp).toLocaleTimeString().slice(-8, -3)}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 flex-shrink-0">
|
||||
{event.machine_name.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-gray-900 font-medium truncate">
|
||||
{event.payload}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400 ml-2 flex-shrink-0">#{event.message_number}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="text-3xl font-bold text-indigo-600">
|
||||
{systemStatus.active_recordings}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-lg font-medium text-gray-900">Active Recordings</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Total: {systemStatus.total_recordings}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{Object.keys(systemStatus.cameras).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-lg font-medium text-gray-900">Cameras</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Machines: {Object.keys(systemStatus.machines).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemStatus && <SystemOverview systemStatus={systemStatus} />}
|
||||
|
||||
|
||||
|
||||
{/* Cameras Status */}
|
||||
{systemStatus && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Cameras</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Current status of all cameras in the system
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-6">
|
||||
{Object.entries(systemStatus.cameras).map(([cameraName, camera]) => {
|
||||
// Debug logging to see what data we're getting
|
||||
console.log(`Camera ${cameraName} data:`, JSON.stringify(camera, null, 2))
|
||||
|
||||
const friendlyName = camera.device_info?.friendly_name
|
||||
const hasDeviceInfo = !!camera.device_info
|
||||
const hasSerial = !!camera.device_info?.serial_number
|
||||
|
||||
return (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{friendlyName ? (
|
||||
<div>
|
||||
<div className="text-lg">{friendlyName}</div>
|
||||
<div className="text-sm text-gray-600 font-normal">({cameraName})</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-lg">{cameraName}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{hasDeviceInfo ? 'Device info available but no friendly name' : 'No device info available'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(camera.status, camera.is_recording)}`}>
|
||||
{camera.is_recording ? 'Recording' : camera.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording:</span>
|
||||
<span className={`font-medium ${camera.is_recording ? 'text-red-600' : 'text-gray-900'}`}>
|
||||
{camera.is_recording ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{camera.device_info?.serial_number && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Serial:</span>
|
||||
<span className="text-gray-900">{camera.device_info.serial_number}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug info - remove this after fixing */}
|
||||
<div className="mt-2 p-2 bg-gray-50 border border-gray-200 rounded text-xs">
|
||||
<div className="font-medium text-gray-700 mb-1">Debug Info:</div>
|
||||
<div className="text-gray-600">
|
||||
<div>Has device_info: {hasDeviceInfo ? 'Yes' : 'No'}</div>
|
||||
<div>Has friendly_name: {friendlyName ? 'Yes' : 'No'}</div>
|
||||
<div>Has serial: {hasSerial ? 'Yes' : 'No'}</div>
|
||||
<div>Last error: {camera.last_error || 'None'}</div>
|
||||
{camera.device_info && (
|
||||
<div className="mt-1">
|
||||
<div>Raw device_info: {JSON.stringify(camera.device_info)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last checked:</span>
|
||||
<span className="text-gray-900">{new Date(camera.last_checked).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
|
||||
{camera.current_recording_file && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording file:</span>
|
||||
<span className="text-gray-900 truncate ml-2">{camera.current_recording_file}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemStatus && <CamerasStatus systemStatus={systemStatus} />}
|
||||
|
||||
{/* Machines Status */}
|
||||
{systemStatus && Object.keys(systemStatus.machines).length > 0 && (
|
||||
@@ -568,168 +693,10 @@ export function VisionSystem() {
|
||||
)}
|
||||
|
||||
{/* Storage Statistics */}
|
||||
{storageStats && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Storage</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Storage usage and file statistics
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{storageStats.total_files}</div>
|
||||
<div className="text-sm text-gray-500">Total Files</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{formatBytes(storageStats.total_size_bytes)}</div>
|
||||
<div className="text-sm text-gray-500">Total Size</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{formatBytes(storageStats.disk_usage.free)}</div>
|
||||
<div className="text-sm text-gray-500">Free Space</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Usage Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Disk Usage</span>
|
||||
<span>{Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(storageStats.disk_usage.used / storageStats.disk_usage.total) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>{formatBytes(storageStats.disk_usage.used)} used</span>
|
||||
<span>{formatBytes(storageStats.disk_usage.total)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-Camera Statistics */}
|
||||
{Object.keys(storageStats.cameras).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Files by Camera</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(storageStats.cameras).map(([cameraName, stats]) => {
|
||||
// Find the corresponding camera to get friendly name
|
||||
const camera = systemStatus?.cameras[cameraName]
|
||||
const displayName = camera?.device_info?.friendly_name || cameraName
|
||||
|
||||
return (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">
|
||||
{camera?.device_info?.friendly_name ? (
|
||||
<>
|
||||
{displayName}
|
||||
<span className="text-gray-500 text-sm font-normal ml-2">({cameraName})</span>
|
||||
</>
|
||||
) : (
|
||||
cameraName
|
||||
)}
|
||||
</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Files:</span>
|
||||
<span className="text-gray-900">{stats.file_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Size:</span>
|
||||
<span className="text-gray-900">{formatBytes(stats.total_size_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{storageStats && <StorageOverview storageStats={storageStats} />}
|
||||
|
||||
{/* Recent Recordings */}
|
||||
{Object.keys(recordings).length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Recent Recordings</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Latest recording sessions
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Camera
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Filename
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Started
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => {
|
||||
// Find the corresponding camera to get friendly name
|
||||
const camera = systemStatus?.cameras[recording.camera_name]
|
||||
const displayName = camera?.device_info?.friendly_name || recording.camera_name
|
||||
|
||||
return (
|
||||
<tr key={recordingId}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{camera?.device_info?.friendly_name ? (
|
||||
<div>
|
||||
<div>{displayName}</div>
|
||||
<div className="text-xs text-gray-500">({recording.camera_name})</div>
|
||||
</div>
|
||||
) : (
|
||||
recording.camera_name
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{recording.filename}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(recording.state)}`}>
|
||||
{recording.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(recording.start_time).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(recordings).length > 0 && <RecentRecordings recordings={recordings} systemStatus={systemStatus} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,17 +23,23 @@ export interface MachineStatus {
|
||||
}
|
||||
|
||||
export interface CameraStatus {
|
||||
name: string
|
||||
name?: string
|
||||
status: string
|
||||
is_recording: boolean
|
||||
last_checked: string
|
||||
last_error: string | null
|
||||
last_error?: string | null
|
||||
device_info?: {
|
||||
friendly_name: string
|
||||
serial_number: string
|
||||
friendly_name?: string
|
||||
serial_number?: string
|
||||
port_type?: string
|
||||
model?: string
|
||||
firmware_version?: string
|
||||
last_checked?: number
|
||||
}
|
||||
current_recording_file: string | null
|
||||
recording_start_time: string | null
|
||||
current_recording_file?: string | null
|
||||
recording_start_time?: string | null
|
||||
last_frame_time?: string
|
||||
frame_rate?: number
|
||||
}
|
||||
|
||||
export interface RecordingInfo {
|
||||
|
||||
566
streaming/AI_INTEGRATION_GUIDE.md
Normal file
566
streaming/AI_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,566 @@
|
||||
# 🤖 AI Integration Guide: USDA Vision Camera Streaming for React Projects
|
||||
|
||||
This guide is specifically designed for AI assistants to understand and implement the USDA Vision Camera streaming functionality in React applications.
|
||||
|
||||
## 📋 System Overview
|
||||
|
||||
The USDA Vision Camera system provides live video streaming through REST API endpoints. The streaming uses MJPEG format which is natively supported by HTML `<img>` tags and can be easily integrated into React components.
|
||||
|
||||
### Key Characteristics:
|
||||
- **Base URL**: `http://localhost:8000` (configurable)
|
||||
- **Stream Format**: MJPEG (Motion JPEG)
|
||||
- **Content-Type**: `multipart/x-mixed-replace; boundary=frame`
|
||||
- **Authentication**: None (add if needed for production)
|
||||
- **CORS**: Enabled for all origins (configure for production)
|
||||
|
||||
## 🔌 API Endpoints Reference
|
||||
|
||||
### 1. Get Camera List
|
||||
```http
|
||||
GET /cameras
|
||||
```
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"camera1": {
|
||||
"name": "camera1",
|
||||
"status": "connected",
|
||||
"is_recording": false,
|
||||
"last_checked": "2025-01-28T10:30:00",
|
||||
"device_info": {...}
|
||||
},
|
||||
"camera2": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Start Camera Stream
|
||||
```http
|
||||
POST /cameras/{camera_name}/start-stream
|
||||
```
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Started streaming for camera camera1"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Stop Camera Stream
|
||||
```http
|
||||
POST /cameras/{camera_name}/stop-stream
|
||||
```
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Stopped streaming for camera camera1"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Live Video Stream
|
||||
```http
|
||||
GET /cameras/{camera_name}/stream
|
||||
```
|
||||
**Response:** MJPEG video stream
|
||||
**Usage:** Set as `src` attribute of HTML `<img>` element
|
||||
|
||||
## ⚛️ React Integration Examples
|
||||
|
||||
### Basic Camera Stream Component
|
||||
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const CameraStream = ({ cameraName, apiBaseUrl = 'http://localhost:8000' }) => {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const startStream = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/cameras/${cameraName}/start-stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsStreaming(true);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.detail || 'Failed to start stream');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Network error: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopStream = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/cameras/${cameraName}/stop-stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.detail || 'Failed to stop stream');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Network error: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="camera-stream">
|
||||
<h3>Camera: {cameraName}</h3>
|
||||
|
||||
{/* Video Stream */}
|
||||
<div className="stream-container">
|
||||
{isStreaming ? (
|
||||
<img
|
||||
src={`${apiBaseUrl}/cameras/${cameraName}/stream?t=${Date.now()}`}
|
||||
alt={`${cameraName} live stream`}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '640px',
|
||||
height: 'auto',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onError={() => setError('Stream connection lost')}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
maxWidth: '640px',
|
||||
height: '360px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<span>No Stream Active</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="stream-controls" style={{ marginTop: '10px' }}>
|
||||
<button
|
||||
onClick={startStream}
|
||||
disabled={loading || isStreaming}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
marginRight: '8px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Start Stream'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={stopStream}
|
||||
disabled={loading || !isStreaming}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Stop Stream'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#f8d7da',
|
||||
color: '#721c24',
|
||||
border: '1px solid #f5c6cb',
|
||||
borderRadius: '4px',
|
||||
}}>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraStream;
|
||||
```
|
||||
|
||||
### Multi-Camera Dashboard Component
|
||||
|
||||
```jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import CameraStream from './CameraStream';
|
||||
|
||||
const CameraDashboard = ({ apiBaseUrl = 'http://localhost:8000' }) => {
|
||||
const [cameras, setCameras] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCameras();
|
||||
|
||||
// Refresh camera status every 30 seconds
|
||||
const interval = setInterval(fetchCameras, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchCameras = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/cameras`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCameras(data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('Failed to fetch cameras');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Network error: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading cameras...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ color: 'red', padding: '20px' }}>
|
||||
Error: {error}
|
||||
<button onClick={fetchCameras} style={{ marginLeft: '10px' }}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="camera-dashboard">
|
||||
<h1>USDA Vision Camera Dashboard</h1>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
||||
gap: '20px',
|
||||
padding: '20px',
|
||||
}}>
|
||||
{Object.entries(cameras).map(([cameraName, cameraInfo]) => (
|
||||
<div key={cameraName} style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '15px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
}}>
|
||||
<CameraStream
|
||||
cameraName={cameraName}
|
||||
apiBaseUrl={apiBaseUrl}
|
||||
/>
|
||||
|
||||
{/* Camera Status */}
|
||||
<div style={{ marginTop: '10px', fontSize: '14px' }}>
|
||||
<div>Status: <strong>{cameraInfo.status}</strong></div>
|
||||
<div>Recording: <strong>{cameraInfo.is_recording ? 'Yes' : 'No'}</strong></div>
|
||||
<div>Last Checked: {new Date(cameraInfo.last_checked).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraDashboard;
|
||||
```
|
||||
|
||||
### Custom Hook for Camera Management
|
||||
|
||||
```jsx
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const useCameraStream = (cameraName, apiBaseUrl = 'http://localhost:8000') => {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const startStream = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/cameras/${cameraName}/start-stream`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsStreaming(true);
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
const errorMsg = errorData.detail || 'Failed to start stream';
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = `Network error: ${err.message}`;
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cameraName, apiBaseUrl]);
|
||||
|
||||
const stopStream = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/cameras/${cameraName}/stop-stream`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsStreaming(false);
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
const errorMsg = errorData.detail || 'Failed to stop stream';
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = `Network error: ${err.message}`;
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cameraName, apiBaseUrl]);
|
||||
|
||||
const getStreamUrl = useCallback(() => {
|
||||
return `${apiBaseUrl}/cameras/${cameraName}/stream?t=${Date.now()}`;
|
||||
}, [cameraName, apiBaseUrl]);
|
||||
|
||||
return {
|
||||
isStreaming,
|
||||
loading,
|
||||
error,
|
||||
startStream,
|
||||
stopStream,
|
||||
getStreamUrl,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCameraStream;
|
||||
```
|
||||
|
||||
## 🎨 Styling with Tailwind CSS
|
||||
|
||||
```jsx
|
||||
const CameraStreamTailwind = ({ cameraName }) => {
|
||||
const { isStreaming, loading, error, startStream, stopStream, getStreamUrl } = useCameraStream(cameraName);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Camera: {cameraName}</h3>
|
||||
|
||||
{/* Stream Container */}
|
||||
<div className="relative mb-4">
|
||||
{isStreaming ? (
|
||||
<img
|
||||
src={getStreamUrl()}
|
||||
alt={`${cameraName} live stream`}
|
||||
className="w-full max-w-2xl h-auto border-2 border-gray-300 rounded-lg"
|
||||
onError={() => setError('Stream connection lost')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full max-w-2xl h-64 bg-gray-100 border-2 border-gray-300 rounded-lg flex items-center justify-center">
|
||||
<span className="text-gray-500">No Stream Active</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={startStream}
|
||||
disabled={loading || isStreaming}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Start Stream'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={stopStream}
|
||||
disabled={loading || !isStreaming}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Stop Stream'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Environment Variables (.env)
|
||||
```env
|
||||
REACT_APP_CAMERA_API_URL=http://localhost:8000
|
||||
REACT_APP_STREAM_REFRESH_INTERVAL=30000
|
||||
REACT_APP_STREAM_TIMEOUT=10000
|
||||
```
|
||||
|
||||
### API Configuration
|
||||
```javascript
|
||||
const apiConfig = {
|
||||
baseUrl: process.env.REACT_APP_CAMERA_API_URL || 'http://localhost:8000',
|
||||
timeout: parseInt(process.env.REACT_APP_STREAM_TIMEOUT) || 10000,
|
||||
refreshInterval: parseInt(process.env.REACT_APP_STREAM_REFRESH_INTERVAL) || 30000,
|
||||
};
|
||||
```
|
||||
|
||||
## 🚨 Important Implementation Notes
|
||||
|
||||
### 1. MJPEG Stream Handling
|
||||
- Use HTML `<img>` tag with `src` pointing to stream endpoint
|
||||
- Add timestamp query parameter to prevent caching: `?t=${Date.now()}`
|
||||
- Handle `onError` event for connection issues
|
||||
|
||||
### 2. Error Handling
|
||||
- Network errors (fetch failures)
|
||||
- HTTP errors (4xx, 5xx responses)
|
||||
- Stream connection errors (img onError)
|
||||
- Timeout handling for long requests
|
||||
|
||||
### 3. Performance Considerations
|
||||
- Streams consume bandwidth continuously
|
||||
- Stop streams when components unmount
|
||||
- Limit concurrent streams based on system capacity
|
||||
- Consider lazy loading for multiple cameras
|
||||
|
||||
### 4. State Management
|
||||
- Track streaming state per camera
|
||||
- Handle loading states during API calls
|
||||
- Manage error states with user feedback
|
||||
- Refresh camera list periodically
|
||||
|
||||
## 📱 Mobile Considerations
|
||||
|
||||
```jsx
|
||||
// Responsive design for mobile
|
||||
const mobileStyles = {
|
||||
container: {
|
||||
padding: '10px',
|
||||
maxWidth: '100vw',
|
||||
},
|
||||
stream: {
|
||||
width: '100%',
|
||||
maxWidth: '100vw',
|
||||
height: 'auto',
|
||||
},
|
||||
controls: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 🧪 Testing Integration
|
||||
|
||||
```javascript
|
||||
// Test API connectivity
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Test camera availability
|
||||
const testCamera = async (cameraName) => {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/cameras/${cameraName}/test-connection`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 📁 Additional Files for AI Integration
|
||||
|
||||
### TypeScript Definitions
|
||||
- `camera-api.types.ts` - Complete TypeScript definitions for all API types
|
||||
- `streaming-api.http` - REST Client file with all streaming endpoints
|
||||
- `STREAMING_GUIDE.md` - Comprehensive user guide for streaming functionality
|
||||
|
||||
### Quick Integration Checklist for AI Assistants
|
||||
|
||||
1. **Copy TypeScript types** from `camera-api.types.ts`
|
||||
2. **Use API endpoints** from `streaming-api.http`
|
||||
3. **Implement error handling** as shown in examples
|
||||
4. **Add CORS configuration** if needed for production
|
||||
5. **Test with multiple cameras** using provided examples
|
||||
|
||||
### Key Integration Points
|
||||
|
||||
- **Stream URL Format**: `${baseUrl}/cameras/${cameraName}/stream?t=${Date.now()}`
|
||||
- **Start Stream**: `POST /cameras/{name}/start-stream`
|
||||
- **Stop Stream**: `POST /cameras/{name}/stop-stream`
|
||||
- **Camera List**: `GET /cameras`
|
||||
- **Error Handling**: Always wrap in try-catch blocks
|
||||
- **Loading States**: Implement for better UX
|
||||
|
||||
### Production Considerations
|
||||
|
||||
- Configure CORS for specific origins
|
||||
- Add authentication if required
|
||||
- Implement rate limiting
|
||||
- Monitor system resources with multiple streams
|
||||
- Add reconnection logic for network issues
|
||||
|
||||
This documentation provides everything an AI assistant needs to integrate the USDA Vision Camera streaming functionality into React applications, including complete code examples, error handling, and best practices.
|
||||
240
streaming/STREAMING_GUIDE.md
Normal file
240
streaming/STREAMING_GUIDE.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# 🎥 USDA Vision Camera Live Streaming Guide
|
||||
|
||||
This guide explains how to use the new live preview streaming functionality that allows you to view camera feeds in real-time without blocking recording operations.
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
- **Non-blocking streaming**: Live preview doesn't interfere with recording
|
||||
- **Separate camera connections**: Streaming uses independent camera instances
|
||||
- **MJPEG streaming**: Standard web-compatible video streaming
|
||||
- **Multiple concurrent viewers**: Multiple browsers can view the same stream
|
||||
- **REST API control**: Start/stop streaming via API endpoints
|
||||
- **Web interface**: Ready-to-use HTML interface for live preview
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The streaming system creates separate camera connections for preview that are independent from recording:
|
||||
|
||||
```
|
||||
Camera Hardware
|
||||
├── Recording Connection (CameraRecorder)
|
||||
│ ├── Used for video file recording
|
||||
│ ├── Triggered by MQTT machine states
|
||||
│ └── High quality, full FPS
|
||||
└── Streaming Connection (CameraStreamer)
|
||||
├── Used for live preview
|
||||
├── Controlled via API endpoints
|
||||
└── Optimized for web viewing (lower FPS, JPEG compression)
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Start the System
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 2. Open the Web Interface
|
||||
Open `camera_preview.html` in your browser and click "Start Stream" for any camera.
|
||||
|
||||
### 3. API Usage
|
||||
```bash
|
||||
# Start streaming for camera1
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
|
||||
# View live stream (open in browser)
|
||||
http://localhost:8000/cameras/camera1/stream
|
||||
|
||||
# Stop streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-stream
|
||||
```
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
### Start Streaming
|
||||
```http
|
||||
POST /cameras/{camera_name}/start-stream
|
||||
```
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Started streaming for camera camera1"
|
||||
}
|
||||
```
|
||||
|
||||
### Stop Streaming
|
||||
```http
|
||||
POST /cameras/{camera_name}/stop-stream
|
||||
```
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Stopped streaming for camera camera1"
|
||||
}
|
||||
```
|
||||
|
||||
### Live Stream (MJPEG)
|
||||
```http
|
||||
GET /cameras/{camera_name}/stream
|
||||
```
|
||||
**Response:** Multipart MJPEG stream
|
||||
**Content-Type:** `multipart/x-mixed-replace; boundary=frame`
|
||||
|
||||
## 🌐 Web Interface Usage
|
||||
|
||||
The included `camera_preview.html` provides a complete web interface:
|
||||
|
||||
1. **Camera Grid**: Shows all configured cameras
|
||||
2. **Stream Controls**: Start/Stop/Refresh buttons for each camera
|
||||
3. **Live Preview**: Real-time video feed display
|
||||
4. **Status Information**: System and camera status
|
||||
5. **Responsive Design**: Works on desktop and mobile
|
||||
|
||||
### Features:
|
||||
- ✅ Real-time camera status
|
||||
- ✅ One-click stream start/stop
|
||||
- ✅ Automatic stream refresh
|
||||
- ✅ System health monitoring
|
||||
- ✅ Error handling and status messages
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### Camera Streamer Configuration
|
||||
- **Preview FPS**: 10 FPS (configurable)
|
||||
- **JPEG Quality**: 70% (configurable)
|
||||
- **Frame Buffer**: 5 frames (prevents memory buildup)
|
||||
- **Timeout**: 200ms per frame capture
|
||||
|
||||
### Memory Management
|
||||
- Automatic frame buffer cleanup
|
||||
- Queue-based frame management
|
||||
- Proper camera resource cleanup on stop
|
||||
|
||||
### Thread Safety
|
||||
- Thread-safe streaming operations
|
||||
- Independent from recording threads
|
||||
- Proper synchronization with locks
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Run the Test Script
|
||||
```bash
|
||||
python test_streaming.py
|
||||
```
|
||||
|
||||
This will test:
|
||||
- ✅ API endpoint functionality
|
||||
- ✅ Stream start/stop operations
|
||||
- ✅ Concurrent recording and streaming
|
||||
- ✅ Error handling
|
||||
|
||||
### Manual Testing
|
||||
1. Start the system: `python main.py`
|
||||
2. Open `camera_preview.html` in browser
|
||||
3. Start streaming for a camera
|
||||
4. Trigger recording via MQTT or manual API
|
||||
5. Verify both work simultaneously
|
||||
|
||||
## 🔄 Concurrent Operations
|
||||
|
||||
The system supports these concurrent operations:
|
||||
|
||||
| Operation | Recording | Streaming | Notes |
|
||||
|-----------|-----------|-----------|-------|
|
||||
| Recording Only | ✅ | ❌ | Normal operation |
|
||||
| Streaming Only | ❌ | ✅ | Preview without recording |
|
||||
| Both Concurrent | ✅ | ✅ | **Independent connections** |
|
||||
|
||||
### Example: Concurrent Usage
|
||||
```bash
|
||||
# Start streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-stream
|
||||
|
||||
# Start recording (while streaming continues)
|
||||
curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"filename": "test_recording.avi"}'
|
||||
|
||||
# Both operations run independently!
|
||||
```
|
||||
|
||||
## 🛠️ Configuration
|
||||
|
||||
### Stream Settings (in CameraStreamer)
|
||||
```python
|
||||
self.preview_fps = 10.0 # Lower FPS for preview
|
||||
self.preview_quality = 70 # JPEG quality (1-100)
|
||||
self._frame_queue.maxsize = 5 # Frame buffer size
|
||||
```
|
||||
|
||||
### Camera Settings
|
||||
The streamer uses the same camera configuration as recording:
|
||||
- Exposure time from `camera_config.exposure_ms`
|
||||
- Gain from `camera_config.gain`
|
||||
- Optimized trigger mode for continuous streaming
|
||||
|
||||
## 🚨 Important Notes
|
||||
|
||||
### Camera Access Patterns
|
||||
- **Recording**: Blocks camera during active recording
|
||||
- **Streaming**: Uses separate connection, doesn't block
|
||||
- **Health Checks**: Brief, non-blocking camera tests
|
||||
- **Multiple Streams**: Multiple browsers can view same stream
|
||||
|
||||
### Performance Considerations
|
||||
- Streaming uses additional CPU/memory resources
|
||||
- Lower preview FPS reduces system load
|
||||
- JPEG compression reduces bandwidth usage
|
||||
- Frame queue prevents memory buildup
|
||||
|
||||
### Error Handling
|
||||
- Automatic camera resource cleanup
|
||||
- Graceful handling of camera disconnections
|
||||
- Stream auto-restart capabilities
|
||||
- Detailed error logging
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Stream Not Starting
|
||||
1. Check camera availability: `GET /cameras`
|
||||
2. Verify camera not in error state
|
||||
3. Check system logs for camera initialization errors
|
||||
4. Try camera reconnection: `POST /cameras/{name}/reconnect`
|
||||
|
||||
### Poor Stream Quality
|
||||
1. Adjust `preview_quality` setting (higher = better quality)
|
||||
2. Increase `preview_fps` for smoother video
|
||||
3. Check network bandwidth
|
||||
4. Verify camera exposure/gain settings
|
||||
|
||||
### Browser Issues
|
||||
1. Try different browser (Chrome/Firefox recommended)
|
||||
2. Check browser console for JavaScript errors
|
||||
3. Verify CORS settings in API server
|
||||
4. Clear browser cache and refresh
|
||||
|
||||
## 📈 Future Enhancements
|
||||
|
||||
Potential improvements for the streaming system:
|
||||
|
||||
- 🔄 WebRTC support for lower latency
|
||||
- 📱 Mobile app integration
|
||||
- 🎛️ Real-time camera setting adjustments
|
||||
- 📊 Stream analytics and monitoring
|
||||
- 🔐 Authentication and access control
|
||||
- 🌐 Multi-camera synchronized viewing
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues with streaming functionality:
|
||||
|
||||
1. Check the system logs: `usda_vision_system.log`
|
||||
2. Run the test script: `python test_streaming.py`
|
||||
3. Verify API health: `http://localhost:8000/health`
|
||||
4. Check camera status: `http://localhost:8000/cameras`
|
||||
|
||||
---
|
||||
|
||||
**✅ Live streaming is now ready for production use!**
|
||||
367
streaming/camera-api.types.ts
Normal file
367
streaming/camera-api.types.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* TypeScript definitions for USDA Vision Camera System API
|
||||
*
|
||||
* This file provides complete type definitions for AI assistants
|
||||
* to integrate the camera streaming functionality into React/TypeScript projects.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// BASE CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
export interface ApiConfig {
|
||||
baseUrl: string;
|
||||
timeout?: number;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export const defaultApiConfig: ApiConfig = {
|
||||
baseUrl: 'http://localhost:8000',
|
||||
timeout: 10000,
|
||||
refreshInterval: 30000,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// CAMERA TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface CameraDeviceInfo {
|
||||
friendly_name?: string;
|
||||
port_type?: string;
|
||||
serial_number?: string;
|
||||
device_index?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CameraInfo {
|
||||
name: string;
|
||||
status: 'connected' | 'disconnected' | 'error' | 'not_found' | 'available';
|
||||
is_recording: boolean;
|
||||
last_checked: string; // ISO date string
|
||||
last_error?: string | null;
|
||||
device_info?: CameraDeviceInfo;
|
||||
current_recording_file?: string | null;
|
||||
recording_start_time?: string | null; // ISO date string
|
||||
}
|
||||
|
||||
export interface CameraListResponse {
|
||||
[cameraName: string]: CameraInfo;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STREAMING TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface StreamStartRequest {
|
||||
// No body required - camera name is in URL path
|
||||
}
|
||||
|
||||
export interface StreamStartResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StreamStopRequest {
|
||||
// No body required - camera name is in URL path
|
||||
}
|
||||
|
||||
export interface StreamStopResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StreamStatus {
|
||||
isStreaming: boolean;
|
||||
streamUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RECORDING TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface StartRecordingRequest {
|
||||
filename?: string;
|
||||
exposure_ms?: number;
|
||||
gain?: number;
|
||||
fps?: number;
|
||||
}
|
||||
|
||||
export interface StartRecordingResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface StopRecordingResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SYSTEM TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface SystemStatusResponse {
|
||||
status: string;
|
||||
uptime: string;
|
||||
api_server_running: boolean;
|
||||
camera_manager_running: boolean;
|
||||
mqtt_client_connected: boolean;
|
||||
total_cameras: number;
|
||||
active_recordings: number;
|
||||
active_streams?: number;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: 'healthy' | 'unhealthy';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ERROR TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ApiError {
|
||||
detail: string;
|
||||
status_code?: number;
|
||||
}
|
||||
|
||||
export interface StreamError extends Error {
|
||||
type: 'network' | 'api' | 'stream' | 'timeout';
|
||||
cameraName: string;
|
||||
originalError?: Error;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HOOK TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface UseCameraStreamResult {
|
||||
isStreaming: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
startStream: () => Promise<{ success: boolean; error?: string }>;
|
||||
stopStream: () => Promise<{ success: boolean; error?: string }>;
|
||||
getStreamUrl: () => string;
|
||||
refreshStream: () => void;
|
||||
}
|
||||
|
||||
export interface UseCameraListResult {
|
||||
cameras: CameraListResponse;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refreshCameras: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseCameraRecordingResult {
|
||||
isRecording: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
currentFile: string | null;
|
||||
startRecording: (options?: StartRecordingRequest) => Promise<{ success: boolean; error?: string }>;
|
||||
stopRecording: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT PROPS TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface CameraStreamProps {
|
||||
cameraName: string;
|
||||
apiConfig?: ApiConfig;
|
||||
autoStart?: boolean;
|
||||
onStreamStart?: (cameraName: string) => void;
|
||||
onStreamStop?: (cameraName: string) => void;
|
||||
onError?: (error: StreamError) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export interface CameraDashboardProps {
|
||||
apiConfig?: ApiConfig;
|
||||
cameras?: string[]; // If provided, only show these cameras
|
||||
showRecordingControls?: boolean;
|
||||
showStreamingControls?: boolean;
|
||||
refreshInterval?: number;
|
||||
onCameraSelect?: (cameraName: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface CameraControlsProps {
|
||||
cameraName: string;
|
||||
apiConfig?: ApiConfig;
|
||||
showRecording?: boolean;
|
||||
showStreaming?: boolean;
|
||||
onAction?: (action: 'start-stream' | 'stop-stream' | 'start-recording' | 'stop-recording', cameraName: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API CLIENT TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface CameraApiClient {
|
||||
// System endpoints
|
||||
getHealth(): Promise<HealthResponse>;
|
||||
getSystemStatus(): Promise<SystemStatusResponse>;
|
||||
|
||||
// Camera endpoints
|
||||
getCameras(): Promise<CameraListResponse>;
|
||||
getCameraStatus(cameraName: string): Promise<CameraInfo>;
|
||||
testCameraConnection(cameraName: string): Promise<{ success: boolean; message: string }>;
|
||||
|
||||
// Streaming endpoints
|
||||
startStream(cameraName: string): Promise<StreamStartResponse>;
|
||||
stopStream(cameraName: string): Promise<StreamStopResponse>;
|
||||
getStreamUrl(cameraName: string): string;
|
||||
|
||||
// Recording endpoints
|
||||
startRecording(cameraName: string, options?: StartRecordingRequest): Promise<StartRecordingResponse>;
|
||||
stopRecording(cameraName: string): Promise<StopRecordingResponse>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// UTILITY TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type CameraAction = 'start-stream' | 'stop-stream' | 'start-recording' | 'stop-recording' | 'test-connection';
|
||||
|
||||
export interface CameraActionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StreamingState {
|
||||
[cameraName: string]: {
|
||||
isStreaming: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastStarted?: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordingState {
|
||||
[cameraName: string]: {
|
||||
isRecording: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
currentFile: string | null;
|
||||
startTime?: Date;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EVENT TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface CameraEvent {
|
||||
type: 'stream-started' | 'stream-stopped' | 'stream-error' | 'recording-started' | 'recording-stopped' | 'recording-error';
|
||||
cameraName: string;
|
||||
timestamp: Date;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export type CameraEventHandler = (event: CameraEvent) => void;
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface StreamConfig {
|
||||
fps: number;
|
||||
quality: number; // 1-100
|
||||
timeout: number;
|
||||
retryAttempts: number;
|
||||
retryDelay: number;
|
||||
}
|
||||
|
||||
export interface CameraStreamConfig extends StreamConfig {
|
||||
cameraName: string;
|
||||
autoReconnect: boolean;
|
||||
maxReconnectAttempts: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONTEXT TYPES (for React Context)
|
||||
// =============================================================================
|
||||
|
||||
export interface CameraContextValue {
|
||||
cameras: CameraListResponse;
|
||||
streamingState: StreamingState;
|
||||
recordingState: RecordingState;
|
||||
apiClient: CameraApiClient;
|
||||
|
||||
// Actions
|
||||
startStream: (cameraName: string) => Promise<CameraActionResult>;
|
||||
stopStream: (cameraName: string) => Promise<CameraActionResult>;
|
||||
startRecording: (cameraName: string, options?: StartRecordingRequest) => Promise<CameraActionResult>;
|
||||
stopRecording: (cameraName: string) => Promise<CameraActionResult>;
|
||||
refreshCameras: () => Promise<void>;
|
||||
|
||||
// State
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXAMPLE USAGE TYPES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Example usage in React component:
|
||||
*
|
||||
* ```typescript
|
||||
* import { CameraStreamProps, UseCameraStreamResult } from './camera-api.types';
|
||||
*
|
||||
* const CameraStream: React.FC<CameraStreamProps> = ({
|
||||
* cameraName,
|
||||
* apiConfig = defaultApiConfig,
|
||||
* autoStart = false,
|
||||
* onStreamStart,
|
||||
* onStreamStop,
|
||||
* onError
|
||||
* }) => {
|
||||
* const {
|
||||
* isStreaming,
|
||||
* loading,
|
||||
* error,
|
||||
* startStream,
|
||||
* stopStream,
|
||||
* getStreamUrl
|
||||
* }: UseCameraStreamResult = useCameraStream(cameraName, apiConfig);
|
||||
*
|
||||
* // Component implementation...
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Example API client usage:
|
||||
*
|
||||
* ```typescript
|
||||
* const apiClient: CameraApiClient = new CameraApiClientImpl(defaultApiConfig);
|
||||
*
|
||||
* // Start streaming
|
||||
* const result = await apiClient.startStream('camera1');
|
||||
* if (result.success) {
|
||||
* const streamUrl = apiClient.getStreamUrl('camera1');
|
||||
* // Use streamUrl in img tag
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Example hook usage:
|
||||
*
|
||||
* ```typescript
|
||||
* const MyComponent = () => {
|
||||
* const { cameras, loading, error, refreshCameras } = useCameraList();
|
||||
* const { isStreaming, startStream, stopStream } = useCameraStream('camera1');
|
||||
*
|
||||
* // Component logic...
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
|
||||
export default {};
|
||||
336
streaming/camera_preview.html
Normal file
336
streaming/camera_preview.html
Normal file
@@ -0,0 +1,336 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>USDA Vision Camera Live Preview</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.camera-card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.camera-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.camera-stream {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #000;
|
||||
min-height: 200px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.camera-controls {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
margin-top: 30px;
|
||||
padding: 15px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.system-info h3 {
|
||||
margin-top: 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.api-info {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎥 USDA Vision Camera Live Preview</h1>
|
||||
|
||||
<div class="camera-grid" id="cameraGrid">
|
||||
<!-- Camera cards will be dynamically generated -->
|
||||
</div>
|
||||
|
||||
<div class="system-info">
|
||||
<h3>📡 System Information</h3>
|
||||
<div id="systemStatus">Loading system status...</div>
|
||||
|
||||
<h3>🔗 API Endpoints</h3>
|
||||
<div class="api-info">
|
||||
<p><strong>Live Stream:</strong> GET /cameras/{camera_name}/stream</p>
|
||||
<p><strong>Start Stream:</strong> POST /cameras/{camera_name}/start-stream</p>
|
||||
<p><strong>Stop Stream:</strong> POST /cameras/{camera_name}/stop-stream</p>
|
||||
<p><strong>Camera Status:</strong> GET /cameras</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = 'http://localhost:8000';
|
||||
let cameras = {};
|
||||
|
||||
// Initialize the page
|
||||
async function init() {
|
||||
await loadCameras();
|
||||
await loadSystemStatus();
|
||||
|
||||
// Refresh status every 5 seconds
|
||||
setInterval(loadSystemStatus, 5000);
|
||||
}
|
||||
|
||||
// Load camera information
|
||||
async function loadCameras() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/cameras`);
|
||||
const data = await response.json();
|
||||
cameras = data;
|
||||
renderCameras();
|
||||
} catch (error) {
|
||||
console.error('Error loading cameras:', error);
|
||||
showError('Failed to load camera information');
|
||||
}
|
||||
}
|
||||
|
||||
// Load system status
|
||||
async function loadSystemStatus() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/system/status`);
|
||||
const data = await response.json();
|
||||
|
||||
const statusDiv = document.getElementById('systemStatus');
|
||||
statusDiv.innerHTML = `
|
||||
<p><strong>System:</strong> ${data.status}</p>
|
||||
<p><strong>Uptime:</strong> ${data.uptime}</p>
|
||||
<p><strong>API Server:</strong> ${data.api_server_running ? '✅ Running' : '❌ Stopped'}</p>
|
||||
<p><strong>Camera Manager:</strong> ${data.camera_manager_running ? '✅ Running' : '❌ Stopped'}</p>
|
||||
<p><strong>MQTT Client:</strong> ${data.mqtt_client_connected ? '✅ Connected' : '❌ Disconnected'}</p>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error loading system status:', error);
|
||||
document.getElementById('systemStatus').innerHTML = '<p style="color: red;">Failed to load system status</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Render camera cards
|
||||
function renderCameras() {
|
||||
const grid = document.getElementById('cameraGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
for (const [cameraName, cameraInfo] of Object.entries(cameras)) {
|
||||
const card = createCameraCard(cameraName, cameraInfo);
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a camera card
|
||||
function createCameraCard(cameraName, cameraInfo) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'camera-card';
|
||||
card.innerHTML = `
|
||||
<div class="camera-title">${cameraName}</div>
|
||||
<img class="camera-stream" id="stream-${cameraName}"
|
||||
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIFN0cmVhbTwvdGV4dD48L3N2Zz4="
|
||||
alt="Camera Stream">
|
||||
<div class="camera-controls">
|
||||
<button class="btn btn-success" onclick="startStream('${cameraName}')">Start Stream</button>
|
||||
<button class="btn btn-danger" onclick="stopStream('${cameraName}')">Stop Stream</button>
|
||||
<button class="btn btn-secondary" onclick="refreshStream('${cameraName}')">Refresh</button>
|
||||
</div>
|
||||
<div class="status status-info" id="status-${cameraName}">
|
||||
Status: ${cameraInfo.status} | Recording: ${cameraInfo.is_recording ? 'Yes' : 'No'}
|
||||
</div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
// Start streaming for a camera
|
||||
async function startStream(cameraName) {
|
||||
try {
|
||||
updateStatus(cameraName, 'Starting stream...', 'info');
|
||||
|
||||
// Start the stream
|
||||
const response = await fetch(`${API_BASE}/cameras/${cameraName}/start-stream`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Set the stream source
|
||||
const streamImg = document.getElementById(`stream-${cameraName}`);
|
||||
streamImg.src = `${API_BASE}/cameras/${cameraName}/stream?t=${Date.now()}`;
|
||||
|
||||
updateStatus(cameraName, 'Stream started successfully', 'success');
|
||||
} else {
|
||||
const error = await response.text();
|
||||
updateStatus(cameraName, `Failed to start stream: ${error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting stream:', error);
|
||||
updateStatus(cameraName, `Error starting stream: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Stop streaming for a camera
|
||||
async function stopStream(cameraName) {
|
||||
try {
|
||||
updateStatus(cameraName, 'Stopping stream...', 'info');
|
||||
|
||||
const response = await fetch(`${API_BASE}/cameras/${cameraName}/stop-stream`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Clear the stream source
|
||||
const streamImg = document.getElementById(`stream-${cameraName}`);
|
||||
streamImg.src = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIFN0cmVhbTwvdGV4dD48L3N2Zz4=";
|
||||
|
||||
updateStatus(cameraName, 'Stream stopped successfully', 'success');
|
||||
} else {
|
||||
const error = await response.text();
|
||||
updateStatus(cameraName, `Failed to stop stream: ${error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping stream:', error);
|
||||
updateStatus(cameraName, `Error stopping stream: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh stream for a camera
|
||||
function refreshStream(cameraName) {
|
||||
const streamImg = document.getElementById(`stream-${cameraName}`);
|
||||
if (streamImg.src.includes('/stream')) {
|
||||
streamImg.src = `${API_BASE}/cameras/${cameraName}/stream?t=${Date.now()}`;
|
||||
updateStatus(cameraName, 'Stream refreshed', 'info');
|
||||
} else {
|
||||
updateStatus(cameraName, 'No active stream to refresh', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Update status message
|
||||
function updateStatus(cameraName, message, type) {
|
||||
const statusDiv = document.getElementById(`status-${cameraName}`);
|
||||
statusDiv.className = `status status-${type}`;
|
||||
statusDiv.textContent = message;
|
||||
}
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
alert(`Error: ${message}`);
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
300
streaming/streaming-api.http
Normal file
300
streaming/streaming-api.http
Normal file
@@ -0,0 +1,300 @@
|
||||
### USDA Vision Camera Streaming API
|
||||
### Base URL: http://localhost:8000
|
||||
###
|
||||
### This file contains streaming-specific API endpoints for live camera preview
|
||||
### Use with VS Code REST Client extension or similar tools.
|
||||
|
||||
@baseUrl = http://localhost:8000
|
||||
|
||||
### =============================================================================
|
||||
### STREAMING ENDPOINTS (NEW FUNCTIONALITY)
|
||||
### =============================================================================
|
||||
|
||||
### Start camera streaming for live preview
|
||||
### This creates a separate camera connection that doesn't interfere with recording
|
||||
POST {{baseUrl}}/cameras/camera1/start-stream
|
||||
Content-Type: application/json
|
||||
|
||||
### Expected Response:
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Started streaming for camera camera1"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Stop camera streaming
|
||||
POST {{baseUrl}}/cameras/camera1/stop-stream
|
||||
Content-Type: application/json
|
||||
|
||||
### Expected Response:
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "Stopped streaming for camera camera1"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Get live MJPEG stream (open in browser or use as img src)
|
||||
### This endpoint returns a continuous MJPEG stream
|
||||
### Content-Type: multipart/x-mixed-replace; boundary=frame
|
||||
GET {{baseUrl}}/cameras/camera1/stream
|
||||
|
||||
### Usage in HTML:
|
||||
# <img src="http://localhost:8000/cameras/camera1/stream" alt="Live Stream" />
|
||||
|
||||
### Usage in React:
|
||||
# <img src={`${apiBaseUrl}/cameras/${cameraName}/stream?t=${Date.now()}`} />
|
||||
|
||||
###
|
||||
|
||||
### Start streaming for camera2
|
||||
POST {{baseUrl}}/cameras/camera2/start-stream
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
|
||||
### Get live stream for camera2
|
||||
GET {{baseUrl}}/cameras/camera2/stream
|
||||
|
||||
###
|
||||
|
||||
### Stop streaming for camera2
|
||||
POST {{baseUrl}}/cameras/camera2/stop-stream
|
||||
Content-Type: application/json
|
||||
|
||||
### =============================================================================
|
||||
### CONCURRENT OPERATIONS TESTING
|
||||
### =============================================================================
|
||||
|
||||
### Test Scenario: Streaming + Recording Simultaneously
|
||||
### This demonstrates that streaming doesn't block recording
|
||||
|
||||
### Step 1: Start streaming first
|
||||
POST {{baseUrl}}/cameras/camera1/start-stream
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
|
||||
### Step 2: Start recording (while streaming continues)
|
||||
POST {{baseUrl}}/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "concurrent_test.avi"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Step 3: Check both are running
|
||||
GET {{baseUrl}}/cameras/camera1
|
||||
|
||||
### Expected Response shows both recording and streaming active:
|
||||
# {
|
||||
# "camera1": {
|
||||
# "name": "camera1",
|
||||
# "status": "connected",
|
||||
# "is_recording": true,
|
||||
# "current_recording_file": "concurrent_test.avi",
|
||||
# "recording_start_time": "2025-01-28T10:30:00.000Z"
|
||||
# }
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Step 4: Stop recording (streaming continues)
|
||||
POST {{baseUrl}}/cameras/camera1/stop-recording
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
|
||||
### Step 5: Verify streaming still works
|
||||
GET {{baseUrl}}/cameras/camera1/stream
|
||||
|
||||
###
|
||||
|
||||
### Step 6: Stop streaming
|
||||
POST {{baseUrl}}/cameras/camera1/stop-stream
|
||||
Content-Type: application/json
|
||||
|
||||
### =============================================================================
|
||||
### MULTIPLE CAMERA STREAMING
|
||||
### =============================================================================
|
||||
|
||||
### Start streaming on multiple cameras simultaneously
|
||||
POST {{baseUrl}}/cameras/camera1/start-stream
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
|
||||
POST {{baseUrl}}/cameras/camera2/start-stream
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
|
||||
### Check status of all cameras
|
||||
GET {{baseUrl}}/cameras
|
||||
|
||||
###
|
||||
|
||||
### Access multiple streams (open in separate browser tabs)
|
||||
GET {{baseUrl}}/cameras/camera1/stream
|
||||
|
||||
###
|
||||
|
||||
GET {{baseUrl}}/cameras/camera2/stream
|
||||
|
||||
###
|
||||
|
||||
### Stop all streaming
|
||||
POST {{baseUrl}}/cameras/camera1/stop-stream
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
|
||||
POST {{baseUrl}}/cameras/camera2/stop-stream
|
||||
Content-Type: application/json
|
||||
|
||||
### =============================================================================
|
||||
### ERROR TESTING
|
||||
### =============================================================================
|
||||
|
||||
### Test with invalid camera name
|
||||
POST {{baseUrl}}/cameras/invalid_camera/start-stream
|
||||
Content-Type: application/json
|
||||
|
||||
### Expected Response:
|
||||
# {
|
||||
# "detail": "Camera streamer not found: invalid_camera"
|
||||
# }
|
||||
|
||||
###
|
||||
|
||||
### Test stream endpoint without starting stream first
|
||||
GET {{baseUrl}}/cameras/camera1/stream
|
||||
|
||||
### Expected: May return error or empty stream depending on camera state
|
||||
|
||||
###
|
||||
|
||||
### Test starting stream when camera is in error state
|
||||
POST {{baseUrl}}/cameras/camera1/start-stream
|
||||
Content-Type: application/json
|
||||
|
||||
### If camera has issues, expected response:
|
||||
# {
|
||||
# "success": false,
|
||||
# "message": "Failed to start streaming for camera camera1"
|
||||
# }
|
||||
|
||||
### =============================================================================
|
||||
### INTEGRATION EXAMPLES FOR AI ASSISTANTS
|
||||
### =============================================================================
|
||||
|
||||
### React Component Integration:
|
||||
# const CameraStream = ({ cameraName }) => {
|
||||
# const [isStreaming, setIsStreaming] = useState(false);
|
||||
#
|
||||
# const startStream = async () => {
|
||||
# const response = await fetch(`${baseUrl}/cameras/${cameraName}/start-stream`, {
|
||||
# method: 'POST'
|
||||
# });
|
||||
# if (response.ok) {
|
||||
# setIsStreaming(true);
|
||||
# }
|
||||
# };
|
||||
#
|
||||
# return (
|
||||
# <div>
|
||||
# <button onClick={startStream}>Start Stream</button>
|
||||
# {isStreaming && (
|
||||
# <img src={`${baseUrl}/cameras/${cameraName}/stream?t=${Date.now()}`} />
|
||||
# )}
|
||||
# </div>
|
||||
# );
|
||||
# };
|
||||
|
||||
### JavaScript Fetch Example:
|
||||
# const streamAPI = {
|
||||
# async startStream(cameraName) {
|
||||
# const response = await fetch(`${baseUrl}/cameras/${cameraName}/start-stream`, {
|
||||
# method: 'POST',
|
||||
# headers: { 'Content-Type': 'application/json' }
|
||||
# });
|
||||
# return response.json();
|
||||
# },
|
||||
#
|
||||
# async stopStream(cameraName) {
|
||||
# const response = await fetch(`${baseUrl}/cameras/${cameraName}/stop-stream`, {
|
||||
# method: 'POST',
|
||||
# headers: { 'Content-Type': 'application/json' }
|
||||
# });
|
||||
# return response.json();
|
||||
# },
|
||||
#
|
||||
# getStreamUrl(cameraName) {
|
||||
# return `${baseUrl}/cameras/${cameraName}/stream?t=${Date.now()}`;
|
||||
# }
|
||||
# };
|
||||
|
||||
### Vue.js Integration:
|
||||
# <template>
|
||||
# <div>
|
||||
# <button @click="startStream">Start Stream</button>
|
||||
# <img v-if="isStreaming" :src="streamUrl" />
|
||||
# </div>
|
||||
# </template>
|
||||
#
|
||||
# <script>
|
||||
# export default {
|
||||
# data() {
|
||||
# return {
|
||||
# isStreaming: false,
|
||||
# cameraName: 'camera1'
|
||||
# };
|
||||
# },
|
||||
# computed: {
|
||||
# streamUrl() {
|
||||
# return `${this.baseUrl}/cameras/${this.cameraName}/stream?t=${Date.now()}`;
|
||||
# }
|
||||
# },
|
||||
# methods: {
|
||||
# async startStream() {
|
||||
# const response = await fetch(`${this.baseUrl}/cameras/${this.cameraName}/start-stream`, {
|
||||
# method: 'POST'
|
||||
# });
|
||||
# if (response.ok) {
|
||||
# this.isStreaming = true;
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# };
|
||||
# </script>
|
||||
|
||||
### =============================================================================
|
||||
### TROUBLESHOOTING
|
||||
### =============================================================================
|
||||
|
||||
### If streams don't start:
|
||||
# 1. Check camera status: GET /cameras
|
||||
# 2. Verify system health: GET /health
|
||||
# 3. Test camera connection: POST /cameras/{name}/test-connection
|
||||
# 4. Check if camera is already recording (shouldn't matter, but good to know)
|
||||
|
||||
### If stream image doesn't load:
|
||||
# 1. Verify stream was started: POST /cameras/{name}/start-stream
|
||||
# 2. Check browser console for CORS errors
|
||||
# 3. Try accessing stream URL directly in browser
|
||||
# 4. Add timestamp to prevent caching: ?t=${Date.now()}
|
||||
|
||||
### If concurrent operations fail:
|
||||
# 1. This should work - streaming and recording use separate connections
|
||||
# 2. Check system logs for resource conflicts
|
||||
# 3. Verify sufficient system resources (CPU/Memory)
|
||||
# 4. Test with one camera first, then multiple
|
||||
|
||||
### Performance Notes:
|
||||
# - Streaming uses ~10 FPS by default (configurable)
|
||||
# - JPEG quality set to 70% (configurable)
|
||||
# - Each stream uses additional CPU/memory
|
||||
# - Multiple concurrent streams may impact performance
|
||||
199
streaming/test_streaming.py
Normal file
199
streaming/test_streaming.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for camera streaming functionality.
|
||||
|
||||
This script tests the new streaming capabilities without interfering with recording.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
# Add the current directory to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def test_api_endpoints():
|
||||
"""Test the streaming API endpoints"""
|
||||
base_url = "http://localhost:8000"
|
||||
|
||||
print("🧪 Testing Camera Streaming API Endpoints")
|
||||
print("=" * 50)
|
||||
|
||||
# Test system status
|
||||
try:
|
||||
response = requests.get(f"{base_url}/system/status", timeout=5)
|
||||
if response.status_code == 200:
|
||||
print("✅ System status endpoint working")
|
||||
data = response.json()
|
||||
print(f" System: {data.get('status', 'Unknown')}")
|
||||
print(f" Camera Manager: {'Running' if data.get('camera_manager_running') else 'Stopped'}")
|
||||
else:
|
||||
print(f"❌ System status endpoint failed: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ System status endpoint error: {e}")
|
||||
|
||||
# Test camera list
|
||||
try:
|
||||
response = requests.get(f"{base_url}/cameras", timeout=5)
|
||||
if response.status_code == 200:
|
||||
print("✅ Camera list endpoint working")
|
||||
cameras = response.json()
|
||||
print(f" Found {len(cameras)} cameras: {list(cameras.keys())}")
|
||||
|
||||
# Test streaming for each camera
|
||||
for camera_name in cameras.keys():
|
||||
test_camera_streaming(base_url, camera_name)
|
||||
|
||||
else:
|
||||
print(f"❌ Camera list endpoint failed: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Camera list endpoint error: {e}")
|
||||
|
||||
def test_camera_streaming(base_url, camera_name):
|
||||
"""Test streaming for a specific camera"""
|
||||
print(f"\n🎥 Testing streaming for {camera_name}")
|
||||
print("-" * 30)
|
||||
|
||||
# Test start streaming
|
||||
try:
|
||||
response = requests.post(f"{base_url}/cameras/{camera_name}/start-stream", timeout=10)
|
||||
if response.status_code == 200:
|
||||
print(f"✅ Start stream endpoint working for {camera_name}")
|
||||
data = response.json()
|
||||
print(f" Response: {data.get('message', 'No message')}")
|
||||
else:
|
||||
print(f"❌ Start stream failed for {camera_name}: {response.status_code}")
|
||||
print(f" Error: {response.text}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"❌ Start stream error for {camera_name}: {e}")
|
||||
return
|
||||
|
||||
# Wait a moment for stream to initialize
|
||||
time.sleep(2)
|
||||
|
||||
# Test stream endpoint (just check if it responds)
|
||||
try:
|
||||
response = requests.get(f"{base_url}/cameras/{camera_name}/stream", timeout=5, stream=True)
|
||||
if response.status_code == 200:
|
||||
print(f"✅ Stream endpoint responding for {camera_name}")
|
||||
print(f" Content-Type: {response.headers.get('content-type', 'Unknown')}")
|
||||
|
||||
# Read a small amount of data to verify it's working
|
||||
chunk_count = 0
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
chunk_count += 1
|
||||
if chunk_count >= 3: # Read a few chunks then stop
|
||||
break
|
||||
|
||||
print(f" Received {chunk_count} data chunks")
|
||||
else:
|
||||
print(f"❌ Stream endpoint failed for {camera_name}: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Stream endpoint error for {camera_name}: {e}")
|
||||
|
||||
# Test stop streaming
|
||||
try:
|
||||
response = requests.post(f"{base_url}/cameras/{camera_name}/stop-stream", timeout=5)
|
||||
if response.status_code == 200:
|
||||
print(f"✅ Stop stream endpoint working for {camera_name}")
|
||||
data = response.json()
|
||||
print(f" Response: {data.get('message', 'No message')}")
|
||||
else:
|
||||
print(f"❌ Stop stream failed for {camera_name}: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"❌ Stop stream error for {camera_name}: {e}")
|
||||
|
||||
def test_concurrent_recording_and_streaming():
|
||||
"""Test that streaming doesn't interfere with recording"""
|
||||
base_url = "http://localhost:8000"
|
||||
|
||||
print("\n🔄 Testing Concurrent Recording and Streaming")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# Get available cameras
|
||||
response = requests.get(f"{base_url}/cameras", timeout=5)
|
||||
if response.status_code != 200:
|
||||
print("❌ Cannot get camera list for concurrent test")
|
||||
return
|
||||
|
||||
cameras = response.json()
|
||||
if not cameras:
|
||||
print("❌ No cameras available for concurrent test")
|
||||
return
|
||||
|
||||
camera_name = list(cameras.keys())[0] # Use first camera
|
||||
print(f"Using camera: {camera_name}")
|
||||
|
||||
# Start streaming
|
||||
print("1. Starting streaming...")
|
||||
response = requests.post(f"{base_url}/cameras/{camera_name}/start-stream", timeout=10)
|
||||
if response.status_code != 200:
|
||||
print(f"❌ Failed to start streaming: {response.text}")
|
||||
return
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# Start recording
|
||||
print("2. Starting recording...")
|
||||
response = requests.post(f"{base_url}/cameras/{camera_name}/start-recording",
|
||||
json={"filename": "test_concurrent_recording.avi"}, timeout=10)
|
||||
if response.status_code == 200:
|
||||
print("✅ Recording started successfully while streaming")
|
||||
else:
|
||||
print(f"❌ Failed to start recording while streaming: {response.text}")
|
||||
|
||||
# Let both run for a few seconds
|
||||
print("3. Running both streaming and recording for 5 seconds...")
|
||||
time.sleep(5)
|
||||
|
||||
# Stop recording
|
||||
print("4. Stopping recording...")
|
||||
response = requests.post(f"{base_url}/cameras/{camera_name}/stop-recording", timeout=5)
|
||||
if response.status_code == 200:
|
||||
print("✅ Recording stopped successfully")
|
||||
else:
|
||||
print(f"❌ Failed to stop recording: {response.text}")
|
||||
|
||||
# Stop streaming
|
||||
print("5. Stopping streaming...")
|
||||
response = requests.post(f"{base_url}/cameras/{camera_name}/stop-stream", timeout=5)
|
||||
if response.status_code == 200:
|
||||
print("✅ Streaming stopped successfully")
|
||||
else:
|
||||
print(f"❌ Failed to stop streaming: {response.text}")
|
||||
|
||||
print("✅ Concurrent test completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Concurrent test error: {e}")
|
||||
|
||||
def main():
|
||||
"""Main test function"""
|
||||
print("🚀 USDA Vision Camera Streaming Test")
|
||||
print("=" * 50)
|
||||
print(f"Test started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print()
|
||||
|
||||
# Wait for system to be ready
|
||||
print("⏳ Waiting for system to be ready...")
|
||||
time.sleep(3)
|
||||
|
||||
# Run tests
|
||||
test_api_endpoints()
|
||||
test_concurrent_recording_and_streaming()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("🏁 Test completed!")
|
||||
print("\n📋 Next Steps:")
|
||||
print("1. Open camera_preview.html in your browser")
|
||||
print("2. Click 'Start Stream' for any camera")
|
||||
print("3. Verify live preview works without blocking recording")
|
||||
print("4. Test concurrent recording and streaming")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -85,15 +85,11 @@ CREATE TRIGGER trigger_experiment_repetitions_updated_at
|
||||
ALTER TABLE public.experiment_repetitions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create RLS policies for experiment_repetitions
|
||||
-- Users can view repetitions for experiments they have access to
|
||||
-- All authenticated users can view all experiment repetitions
|
||||
CREATE POLICY "Users can view experiment repetitions" ON public.experiment_repetitions
|
||||
FOR SELECT USING (
|
||||
experiment_id IN (
|
||||
SELECT id FROM public.experiments
|
||||
WHERE created_by = auth.uid()
|
||||
)
|
||||
OR public.is_admin()
|
||||
);
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Users can insert repetitions for experiments they created or if they're admin
|
||||
CREATE POLICY "Users can create experiment repetitions" ON public.experiment_repetitions
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Fix experiment repetitions visibility for all users
|
||||
-- This migration updates the RLS policy to allow all authenticated users to view all experiment repetitions
|
||||
-- Previously, users could only see repetitions for experiments they created
|
||||
|
||||
-- Drop the existing restrictive policy
|
||||
DROP POLICY IF EXISTS "Users can view experiment repetitions" ON public.experiment_repetitions;
|
||||
|
||||
-- Create new policy that allows all authenticated users to view all repetitions
|
||||
CREATE POLICY "Users can view experiment repetitions" ON public.experiment_repetitions
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
Reference in New Issue
Block a user