Parcourir la source

Camera stream is now reconnecting if stream stucks

maziggy il y a 4 mois
Parent
commit
a308248880

+ 82 - 8
backend/app/api/routes/camera.py

@@ -33,6 +33,12 @@ _active_chamber_streams: dict[str, tuple] = {}
 # Store last frame for each printer (for photo capture from active stream)
 _last_frames: dict[int, bytes] = {}
 
+# Track last frame timestamp for each printer (for stall detection)
+_last_frame_times: dict[int, float] = {}
+
+# Track stream start times for each printer
+_stream_start_times: dict[int, float] = {}
+
 
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
@@ -98,9 +104,10 @@ async def generate_chamber_mjpeg_stream(
                 logger.warning(f"Chamber image stream ended for {stream_id}")
                 break
 
-            # Save frame to buffer for photo capture
+            # Save frame to buffer for photo capture and track timestamp
             if printer_id is not None:
                 _last_frames[printer_id] = frame
+                _last_frame_times[printer_id] = asyncio.get_event_loop().time()
 
             # Rate limiting - skip frames if needed to maintain target FPS
             current_time = asyncio.get_event_loop().time()
@@ -127,9 +134,11 @@ async def generate_chamber_mjpeg_stream(
         if stream_id and stream_id in _active_chamber_streams:
             del _active_chamber_streams[stream_id]
 
-        # Clean up frame buffer
-        if printer_id is not None and printer_id in _last_frames:
-            del _last_frames[printer_id]
+        # Clean up frame buffer and timestamps
+        if printer_id is not None:
+            _last_frames.pop(printer_id, None)
+            _last_frame_times.pop(printer_id, None)
+            _stream_start_times.pop(printer_id, None)
 
         # Close the connection
         try:
@@ -265,9 +274,10 @@ async def generate_rtsp_mjpeg_stream(
                     frame = buffer[: end_idx + 2]
                     buffer = buffer[end_idx + 2 :]
 
-                    # Save frame to buffer for photo capture
+                    # Save frame to buffer for photo capture and track timestamp
                     if printer_id is not None:
                         _last_frames[printer_id] = frame
+                        _last_frame_times[printer_id] = asyncio.get_event_loop().time()
 
                     # Yield frame in MJPEG format
                     yield (
@@ -301,9 +311,11 @@ async def generate_rtsp_mjpeg_stream(
         if stream_id and stream_id in _active_streams:
             del _active_streams[stream_id]
 
-        # Clean up frame buffer
-        if printer_id is not None and printer_id in _last_frames:
-            del _last_frames[printer_id]
+        # Clean up frame buffer and timestamps
+        if printer_id is not None:
+            _last_frames.pop(printer_id, None)
+            _last_frame_times.pop(printer_id, None)
+            _stream_start_times.pop(printer_id, None)
 
         if process and process.returncode is None:
             logger.info(f"Terminating ffmpeg process for stream {stream_id}")
@@ -366,6 +378,11 @@ async def camera_stream(
         stream_generator = generate_rtsp_mjpeg_stream
         logger.info(f"Using RTSP protocol for {printer.model}")
 
+    # Track stream start time
+    import time
+
+    _stream_start_times[printer_id] = time.time()
+
     async def stream_with_disconnect_check():
         """Wrapper generator that monitors for client disconnect."""
         try:
@@ -519,3 +536,60 @@ async def test_camera(
     )
 
     return result
+
+
+@router.get("/{printer_id}/camera/status")
+async def camera_status(printer_id: int):
+    """Get the status of an active camera stream.
+
+    Returns whether a stream is active and when the last frame was received.
+    Used by the frontend to detect stalled streams and auto-reconnect.
+    """
+    import time
+
+    # Check if there's an active stream for this printer
+    has_active_stream = False
+
+    # Check ffmpeg/RTSP streams
+    for stream_id in _active_streams:
+        if stream_id.startswith(f"{printer_id}-"):
+            process = _active_streams[stream_id]
+            if process.returncode is None:
+                has_active_stream = True
+                break
+
+    # Check chamber image streams
+    if not has_active_stream:
+        for stream_id in _active_chamber_streams:
+            if stream_id.startswith(f"{printer_id}-"):
+                has_active_stream = True
+                break
+
+    # Get timing information
+    current_time = time.time()
+    last_frame_time = _last_frame_times.get(printer_id)
+    stream_start_time = _stream_start_times.get(printer_id)
+
+    # Calculate seconds since last frame
+    seconds_since_frame = None
+    if last_frame_time is not None:
+        seconds_since_frame = current_time - last_frame_time
+
+    # Calculate stream uptime
+    stream_uptime = None
+    if stream_start_time is not None:
+        stream_uptime = current_time - stream_start_time
+
+    return {
+        "active": has_active_stream,
+        "has_frames": printer_id in _last_frames,
+        "seconds_since_frame": seconds_since_frame,
+        "stream_uptime": stream_uptime,
+        # Consider stalled if no frame for more than 10 seconds after stream started
+        "stalled": (
+            has_active_stream
+            and stream_uptime is not None
+            and stream_uptime > 5  # Give 5 seconds for stream to start
+            and (seconds_since_frame is None or seconds_since_frame > 10)
+        ),
+    }

+ 47 - 0
frontend/src/pages/CameraPage.tsx

@@ -7,6 +7,7 @@ import { api } from '../api/client';
 const MAX_RECONNECT_ATTEMPTS = 5;
 const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
 const MAX_RECONNECT_DELAY = 30000; // 30 seconds
+const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
 
 export function CameraPage() {
   const { printerId } = useParams<{ printerId: string }>();
@@ -25,6 +26,7 @@ export function CameraPage() {
   const containerRef = useRef<HTMLDivElement>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
+  const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
   // Fetch printer info for the title
   const { data: printer } = useQuery({
@@ -146,6 +148,9 @@ export function CameraPage() {
       if (countdownIntervalRef.current) {
         clearInterval(countdownIntervalRef.current);
       }
+      if (stallCheckIntervalRef.current) {
+        clearInterval(stallCheckIntervalRef.current);
+      }
     };
   }, []);
 
@@ -192,6 +197,48 @@ export function CameraPage() {
     }, delay);
   }, [reconnectAttempts]);
 
+  // Stall detection - periodically check if stream is still receiving frames
+  useEffect(() => {
+    if (streamMode !== 'stream' || streamLoading || streamError || isReconnecting || transitioning) {
+      // Clear stall check when not actively streaming
+      if (stallCheckIntervalRef.current) {
+        clearInterval(stallCheckIntervalRef.current);
+        stallCheckIntervalRef.current = null;
+      }
+      return;
+    }
+
+    // Start stall detection after stream has loaded
+    stallCheckIntervalRef.current = setInterval(async () => {
+      try {
+        const response = await fetch(`/api/v1/printers/${id}/camera/status`);
+        if (response.ok) {
+          const status = await response.json();
+          if (status.stalled) {
+            console.log('Stream stall detected, auto-reconnecting...');
+            // Trigger reconnect
+            if (stallCheckIntervalRef.current) {
+              clearInterval(stallCheckIntervalRef.current);
+              stallCheckIntervalRef.current = null;
+            }
+            // Use the same reconnect logic as stream error
+            setStreamLoading(false);
+            attemptReconnect();
+          }
+        }
+      } catch {
+        // Ignore fetch errors - server might be temporarily unavailable
+      }
+    }, STALL_CHECK_INTERVAL);
+
+    return () => {
+      if (stallCheckIntervalRef.current) {
+        clearInterval(stallCheckIntervalRef.current);
+        stallCheckIntervalRef.current = null;
+      }
+    };
+  }, [streamMode, streamLoading, streamError, isReconnecting, transitioning, id, attemptReconnect]);
+
   const handleStreamError = () => {
     setStreamLoading(false);
 

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-Buplsh8i.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CfJSg4r6.js"></script>
+    <script type="module" crossorigin src="/assets/index-Buplsh8i.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-j3n1gAEX.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff