maziggy 5 months ago
parent
commit
e688c35719

+ 28 - 4
backend/app/api/routes/camera.py

@@ -15,6 +15,8 @@ from backend.app.services.camera import (
     build_camera_url,
     capture_camera_frame,
     test_camera_connection,
+    get_ffmpeg_path,
+    get_camera_port,
 )
 from backend.app.services.printer_manager import printer_manager
 
@@ -41,20 +43,29 @@ async def generate_mjpeg_stream(
 
     This captures frames continuously and yields them in MJPEG format.
     """
-    from backend.app.services.camera import get_camera_port
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - camera streaming requires ffmpeg")
+        yield (
+            b"--frame\r\n"
+            b"Content-Type: text/plain\r\n\r\n"
+            b"Error: ffmpeg not installed\r\n"
+        )
+        return
 
     port = get_camera_port(model)
     camera_url = f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
 
     # ffmpeg command to output MJPEG stream to stdout
-    # -re: Read input at native frame rate
     # -rtsp_transport tcp: Use TCP for reliability
+    # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
     # -f mjpeg: Output as MJPEG
     # -q:v 5: Quality (lower = better, 2-10 is good range)
     # -r: Output framerate
     cmd = [
-        "ffmpeg",
+        ffmpeg,
         "-rtsp_transport", "tcp",
+        "-rtsp_flags", "prefer_tcp",
         "-i", camera_url,
         "-f", "mjpeg",
         "-q:v", "5",
@@ -63,7 +74,8 @@ async def generate_mjpeg_stream(
         "-"  # Output to stdout
     ]
 
-    logger.info(f"Starting camera stream for {ip_address}")
+    logger.info(f"Starting camera stream for {ip_address} using URL: rtsps://bblp:***@{ip_address}:{port}/streaming/live/1")
+    logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
 
     process = None
     try:
@@ -73,6 +85,18 @@ async def generate_mjpeg_stream(
             stderr=asyncio.subprocess.PIPE,
         )
 
+        # Give ffmpeg a moment to start and check for immediate failures
+        await asyncio.sleep(0.5)
+        if process.returncode is not None:
+            stderr = await process.stderr.read()
+            logger.error(f"ffmpeg failed immediately: {stderr.decode()}")
+            yield (
+                b"--frame\r\n"
+                b"Content-Type: text/plain\r\n\r\n"
+                b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n"
+            )
+            return
+
         # Read JPEG frames from ffmpeg output
         # JPEG images start with 0xFFD8 and end with 0xFFD9
         buffer = b""

+ 5 - 1
backend/app/api/routes/printers.py

@@ -190,8 +190,8 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
             # humidity_raw is the actual percentage value from the sensor
             humidity_raw = ams_data.get("humidity_raw")
             humidity_idx = ams_data.get("humidity")
-            # Use humidity_raw if available, otherwise fall back to humidity index
             humidity_value = None
+
             if humidity_raw is not None:
                 try:
                     humidity_value = int(humidity_raw)
@@ -202,10 +202,14 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
                     humidity_value = int(humidity_idx)
                 except (ValueError, TypeError):
                     pass
+            # AMS-HT has 1 tray, regular AMS has 4 trays
+            is_ams_ht = len(trays) == 1
+
             ams_units.append(AMSUnit(
                 id=ams_data.get("id", 0),
                 humidity=humidity_value,
                 temp=ams_data.get("temp"),
+                is_ams_ht=is_ams_ht,
                 tray=trays,
             ))
 

+ 3 - 18
backend/app/api/routes/settings.py

@@ -1,6 +1,3 @@
-import shutil
-from pathlib import Path
-
 from fastapi import APIRouter, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
@@ -95,21 +92,9 @@ async def reset_settings(db: AsyncSession = Depends(get_db)):
 @router.get("/check-ffmpeg")
 async def check_ffmpeg():
     """Check if ffmpeg is installed and available."""
-    ffmpeg_path = shutil.which("ffmpeg")
-
-    # If not found via PATH, check common installation locations
-    # (systemd services often have limited PATH)
-    if ffmpeg_path is None:
-        common_paths = [
-            "/usr/bin/ffmpeg",
-            "/usr/local/bin/ffmpeg",
-            "/opt/homebrew/bin/ffmpeg",
-            "/snap/bin/ffmpeg",
-        ]
-        for path in common_paths:
-            if Path(path).exists():
-                ffmpeg_path = path
-                break
+    from backend.app.services.camera import get_ffmpeg_path
+
+    ffmpeg_path = get_ffmpeg_path()
 
     return {
         "installed": ffmpeg_path is not None,

+ 9 - 0
backend/app/core/database.py

@@ -100,3 +100,12 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+    # Migration: Add location column to printers for grouping
+    try:
+        await conn.execute(text(
+            "ALTER TABLE printers ADD COLUMN location VARCHAR(100)"
+        ))
+    except Exception:
+        # Column already exists
+        pass

+ 1 - 0
backend/app/models/printer.py

@@ -14,6 +14,7 @@ class Printer(Base):
     ip_address: Mapped[str] = mapped_column(String(45))
     access_code: Mapped[str] = mapped_column(String(20))
     model: Mapped[str | None] = mapped_column(String(50))
+    location: Mapped[str | None] = mapped_column(String(100))  # Group/location name
     nozzle_count: Mapped[int] = mapped_column(default=1)  # 1 or 2, auto-detected from MQTT
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)

+ 3 - 0
backend/app/schemas/printer.py

@@ -8,6 +8,7 @@ class PrinterBase(BaseModel):
     ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
     access_code: str = Field(..., min_length=1, max_length=20)
     model: str | None = None
+    location: str | None = None  # Group/location name
     auto_archive: bool = True
 
 
@@ -20,6 +21,7 @@ class PrinterUpdate(BaseModel):
     ip_address: str | None = None
     access_code: str | None = None
     model: str | None = None
+    location: str | None = None
     is_active: bool | None = None
     auto_archive: bool | None = None
     print_hours_offset: float | None = None
@@ -63,6 +65,7 @@ class AMSUnit(BaseModel):
     id: int
     humidity: int | None = None
     temp: float | None = None
+    is_ams_ht: bool = False  # True for AMS-HT (single spool), False for regular AMS (4 spools)
     tray: list[AMSTray] = []
 
 

+ 51 - 2
backend/app/services/camera.py

@@ -5,7 +5,7 @@ Captures images from the printer's RTSPS camera stream using ffmpeg.
 
 import asyncio
 import logging
-import subprocess
+import shutil
 from pathlib import Path
 from datetime import datetime
 import uuid
@@ -14,6 +14,46 @@ from backend.app.core.config import settings
 
 logger = logging.getLogger(__name__)
 
+# Cache the ffmpeg path after first lookup
+_ffmpeg_path: str | None = None
+
+
+def get_ffmpeg_path() -> str | None:
+    """Find the ffmpeg executable path.
+
+    Uses shutil.which first, then checks common installation locations
+    for systems where PATH may be limited (e.g., systemd services).
+    """
+    global _ffmpeg_path
+
+    if _ffmpeg_path is not None:
+        return _ffmpeg_path
+
+    # Try PATH first
+    ffmpeg_path = shutil.which("ffmpeg")
+
+    # If not found via PATH, check common installation locations
+    if ffmpeg_path is None:
+        common_paths = [
+            "/usr/bin/ffmpeg",
+            "/usr/local/bin/ffmpeg",
+            "/opt/homebrew/bin/ffmpeg",  # macOS Homebrew
+            "/snap/bin/ffmpeg",  # Ubuntu Snap
+            "C:\\ffmpeg\\bin\\ffmpeg.exe",  # Windows common
+        ]
+        for path in common_paths:
+            if Path(path).exists():
+                ffmpeg_path = path
+                break
+
+    _ffmpeg_path = ffmpeg_path
+    if ffmpeg_path:
+        logger.info(f"Found ffmpeg at: {ffmpeg_path}")
+    else:
+        logger.warning("ffmpeg not found in PATH or common locations")
+
+    return ffmpeg_path
+
 
 def get_camera_port(model: str | None) -> int:
     """Get the RTSPS port based on printer model.
@@ -59,17 +99,26 @@ async def capture_camera_frame(
     # Ensure output directory exists
     output_path.parent.mkdir(parents=True, exist_ok=True)
 
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
+        return False
+
     # ffmpeg command to capture a single frame from RTSPS stream
     # -rtsp_transport tcp: Use TCP for RTSP (more reliable)
+    # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
     # -y: Overwrite output file
     # -frames:v 1: Capture only 1 frame
+    # -update 1: Allow writing single image without sequence pattern
     # -q:v 2: High quality JPEG (1-31, lower is better)
     cmd = [
-        "ffmpeg",
+        ffmpeg,
         "-y",  # Overwrite output
         "-rtsp_transport", "tcp",
+        "-rtsp_flags", "prefer_tcp",
         "-i", camera_url,
         "-frames:v", "1",
+        "-update", "1",
         "-q:v", "2",
         str(output_path),
     ]

+ 71 - 0
backend/app/services/printer_manager.py

@@ -268,6 +268,74 @@ class PrinterManager:
 
 def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
     """Convert PrinterState to a JSON-serializable dict."""
+    # Parse AMS data from raw_data
+    ams_units = []
+    vt_tray = None
+    raw_data = state.raw_data or {}
+
+    if "ams" in raw_data and isinstance(raw_data["ams"], list):
+        for ams_data in raw_data["ams"]:
+            trays = []
+            for tray in ams_data.get("tray", []):
+                tag_uid = tray.get("tag_uid")
+                if tag_uid in ("", "0000000000000000"):
+                    tag_uid = None
+                tray_uuid = tray.get("tray_uuid")
+                if tray_uuid in ("", "00000000000000000000000000000000"):
+                    tray_uuid = None
+                trays.append({
+                    "id": tray.get("id", 0),
+                    "tray_color": tray.get("tray_color"),
+                    "tray_type": tray.get("tray_type"),
+                    "tray_sub_brands": tray.get("tray_sub_brands"),
+                    "remain": tray.get("remain", 0),
+                    "k": tray.get("k"),
+                    "tag_uid": tag_uid,
+                    "tray_uuid": tray_uuid,
+                })
+            # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
+            humidity_raw = ams_data.get("humidity_raw")
+            humidity_idx = ams_data.get("humidity")
+            humidity_value = None
+
+            if humidity_raw is not None:
+                try:
+                    humidity_value = int(humidity_raw)
+                except (ValueError, TypeError):
+                    pass
+            # Fall back to index if no raw value (index is 1-5, not percentage)
+            if humidity_value is None and humidity_idx is not None:
+                try:
+                    humidity_value = int(humidity_idx)
+                except (ValueError, TypeError):
+                    pass
+
+            # AMS-HT has 1 tray, regular AMS has 4 trays
+            is_ams_ht = len(trays) == 1
+
+            ams_units.append({
+                "id": ams_data.get("id", 0),
+                "humidity": humidity_value,
+                "temp": ams_data.get("temp"),
+                "is_ams_ht": is_ams_ht,
+                "tray": trays,
+            })
+
+    # Parse virtual tray (external spool)
+    if "vt_tray" in raw_data:
+        vt_data = raw_data["vt_tray"]
+        vt_tag_uid = vt_data.get("tag_uid")
+        if vt_tag_uid in ("", "0000000000000000"):
+            vt_tag_uid = None
+        vt_tray = {
+            "id": 254,
+            "tray_color": vt_data.get("tray_color"),
+            "tray_type": vt_data.get("tray_type"),
+            "tray_sub_brands": vt_data.get("tray_sub_brands"),
+            "remain": vt_data.get("remain", 0),
+            "tag_uid": vt_tag_uid,
+        }
+
     result = {
         "connected": state.connected,
         "state": state.state,
@@ -283,6 +351,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
             {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
             for e in (state.hms_errors or [])
         ],
+        # AMS data for filament colors
+        "ams": ams_units if ams_units else None,
+        "vt_tray": vt_tray,
         # AMS status for filament change tracking
         "ams_status_main": state.ams_status_main,
         "ams_status_sub": state.ams_status_sub,

+ 4 - 0
frontend/src/App.tsx

@@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
+import { CameraPage } from './pages/CameraPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -34,6 +35,9 @@ function App() {
           <WebSocketProvider>
             <BrowserRouter>
               <Routes>
+                {/* Camera page - standalone, no layout */}
+                <Route path="/camera/:printerId" element={<CameraPage />} />
+
                 <Route path="/" element={<Layout />}>
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />

+ 5 - 0
frontend/src/api/client.ts

@@ -28,6 +28,7 @@ export interface Printer {
   ip_address: string;
   access_code: string;
   model: string | null;
+  location: string | null;  // Group/location name
   nozzle_count: number;  // 1 or 2, auto-detected from MQTT
   is_active: boolean;
   auto_archive: boolean;
@@ -61,6 +62,7 @@ export interface AMSUnit {
   id: number;
   humidity: number | null;
   temp: number | null;
+  is_ams_ht: boolean;  // True for AMS-HT (single spool), False for regular AMS (4 spools)
   tray: AMSTray[];
 }
 
@@ -106,6 +108,8 @@ export interface PrinterStatus {
     bed_target?: number;
     nozzle?: number;
     nozzle_target?: number;
+    nozzle_2?: number;  // Second nozzle for H2 series (dual nozzle)
+    nozzle_2_target?: number;
     chamber?: number;
   } | null;
   cover_url: string | null;
@@ -157,6 +161,7 @@ export interface PrinterCreate {
   ip_address: string;
   access_code: string;
   model?: string;
+  location?: string;
   auto_archive?: boolean;
 }
 

+ 10 - 13
frontend/src/components/PrinterQueueWidget.tsx

@@ -35,11 +35,14 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
   }
 
   return (
-    <div className="mt-3 p-2 bg-bambu-dark rounded-lg">
-      <div className="flex items-center justify-between">
-        <div className="flex items-center gap-2 min-w-0">
-          <Calendar className="w-4 h-4 text-yellow-400 flex-shrink-0" />
-          <div className="min-w-0">
+    <Link
+      to="/queue"
+      className="block mb-3 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+    >
+      <div className="flex items-center justify-between gap-3">
+        <div className="flex items-center gap-3 min-w-0 flex-1">
+          <Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
+          <div className="min-w-0 flex-1">
             <p className="text-xs text-bambu-gray">Next in queue</p>
             <p className="text-sm text-white truncate">
               {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
@@ -56,15 +59,9 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
               +{totalPending - 1}
             </span>
           )}
-          <Link
-            to="/queue"
-            className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors text-bambu-gray hover:text-white"
-            title="View queue"
-          >
-            <ChevronRight className="w-4 h-4" />
-          </Link>
+          <ChevronRight className="w-4 h-4 text-bambu-gray" />
         </div>
       </div>
-    </div>
+    </Link>
   );
 }

+ 21 - 0
frontend/src/index.css

@@ -122,3 +122,24 @@ body {
 .dark .jogpad-theme {
   filter: brightness(0.35) contrast(1.2);
 }
+
+/* Empty AMS slot with diagonal stripes */
+.ams-empty-slot {
+  background: repeating-linear-gradient(
+    45deg,
+    #444,
+    #444 2px,
+    #222 2px,
+    #222 4px
+  );
+}
+
+.dark .ams-empty-slot {
+  background: repeating-linear-gradient(
+    45deg,
+    #555,
+    #555 2px,
+    #333 2px,
+    #333 4px
+  );
+}

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

@@ -0,0 +1,233 @@
+import { useState, useEffect, useRef } from 'react';
+import { useParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize } from 'lucide-react';
+import { api } from '../api/client';
+
+export function CameraPage() {
+  const { printerId } = useParams<{ printerId: string }>();
+  const id = parseInt(printerId || '0', 10);
+
+  const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
+  const [streamError, setStreamError] = useState(false);
+  const [streamLoading, setStreamLoading] = useState(true);
+  const [imageKey, setImageKey] = useState(Date.now());
+  const [transitioning, setTransitioning] = useState(false);
+  const [isFullscreen, setIsFullscreen] = useState(false);
+  const imgRef = useRef<HTMLImageElement>(null);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  // Fetch printer info for the title
+  const { data: printer } = useQuery({
+    queryKey: ['printer', id],
+    queryFn: () => api.getPrinter(id),
+    enabled: id > 0,
+  });
+
+  // Update document title
+  useEffect(() => {
+    if (printer) {
+      document.title = `${printer.name} - Camera`;
+    }
+    return () => {
+      document.title = 'Bambusy';
+    };
+  }, [printer]);
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      if (imgRef.current) {
+        imgRef.current.src = '';
+      }
+    };
+  }, []);
+
+  // Auto-hide loading after timeout
+  useEffect(() => {
+    if (streamLoading && !transitioning) {
+      const timeout = streamMode === 'stream' ? 3000 : 20000;
+      const timer = setTimeout(() => {
+        setStreamLoading(false);
+      }, timeout);
+      return () => clearTimeout(timer);
+    }
+  }, [streamMode, streamLoading, imageKey, transitioning]);
+
+  // Fullscreen change listener
+  useEffect(() => {
+    const handleFullscreenChange = () => {
+      setIsFullscreen(!!document.fullscreenElement);
+    };
+    document.addEventListener('fullscreenchange', handleFullscreenChange);
+    return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
+  }, []);
+
+  const handleStreamError = () => {
+    setStreamError(true);
+    setStreamLoading(false);
+  };
+
+  const handleStreamLoad = () => {
+    setStreamLoading(false);
+    setStreamError(false);
+  };
+
+  const switchToMode = (newMode: 'stream' | 'snapshot') => {
+    if (streamMode === newMode || transitioning) return;
+    setTransitioning(true);
+    setStreamLoading(true);
+    setStreamError(false);
+
+    if (imgRef.current) {
+      imgRef.current.src = '';
+    }
+
+    setTimeout(() => {
+      setStreamMode(newMode);
+      setImageKey(Date.now());
+      setTransitioning(false);
+    }, 100);
+  };
+
+  const refresh = () => {
+    if (transitioning) return;
+    setTransitioning(true);
+    setStreamLoading(true);
+    setStreamError(false);
+
+    if (imgRef.current) {
+      imgRef.current.src = '';
+    }
+
+    setTimeout(() => {
+      setImageKey(Date.now());
+      setTransitioning(false);
+    }, 100);
+  };
+
+  const toggleFullscreen = () => {
+    if (!containerRef.current) return;
+    if (document.fullscreenElement) {
+      document.exitFullscreen();
+    } else {
+      containerRef.current.requestFullscreen();
+    }
+  };
+
+  const currentUrl = transitioning
+    ? ''
+    : streamMode === 'stream'
+      ? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
+      : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
+
+  const isDisabled = streamLoading || transitioning;
+
+  if (!id) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center">
+        <p className="text-white">Invalid printer ID</p>
+      </div>
+    );
+  }
+
+  return (
+    <div ref={containerRef} className="min-h-screen bg-black flex flex-col">
+      {/* Header */}
+      <div className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
+        <h1 className="text-sm font-medium text-white flex items-center gap-2">
+          <Camera className="w-4 h-4" />
+          {printer?.name || `Printer ${id}`}
+        </h1>
+        <div className="flex items-center gap-2">
+          {/* Mode toggle */}
+          <div className="flex bg-bambu-dark rounded p-0.5">
+            <button
+              onClick={() => switchToMode('stream')}
+              disabled={isDisabled}
+              className={`px-3 py-1 text-xs rounded transition-colors ${
+                streamMode === 'stream'
+                  ? 'bg-bambu-green text-white'
+                  : 'text-bambu-gray hover:text-white disabled:opacity-50'
+              }`}
+            >
+              Live
+            </button>
+            <button
+              onClick={() => switchToMode('snapshot')}
+              disabled={isDisabled}
+              className={`px-3 py-1 text-xs rounded transition-colors ${
+                streamMode === 'snapshot'
+                  ? 'bg-bambu-green text-white'
+                  : 'text-bambu-gray hover:text-white disabled:opacity-50'
+              }`}
+            >
+              Snapshot
+            </button>
+          </div>
+          <button
+            onClick={refresh}
+            disabled={isDisabled}
+            className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
+            title={streamMode === 'stream' ? 'Restart stream' : 'Refresh snapshot'}
+          >
+            <RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
+          </button>
+          <button
+            onClick={toggleFullscreen}
+            className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
+            title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
+          >
+            {isFullscreen ? (
+              <Minimize className="w-4 h-4 text-bambu-gray" />
+            ) : (
+              <Maximize className="w-4 h-4 text-bambu-gray" />
+            )}
+          </button>
+        </div>
+      </div>
+
+      {/* Video area */}
+      <div className="flex-1 flex items-center justify-center p-2">
+        <div className="relative w-full h-full flex items-center justify-center">
+          {(streamLoading || transitioning) && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
+              <div className="text-center">
+                <RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
+                <p className="text-sm text-bambu-gray">
+                  {streamMode === 'stream' ? 'Connecting to camera...' : 'Capturing snapshot...'}
+                </p>
+              </div>
+            </div>
+          )}
+          {streamError && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
+              <div className="text-center p-4">
+                <AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
+                <p className="text-white mb-2">Camera unavailable</p>
+                <p className="text-xs text-bambu-gray mb-4 max-w-md">
+                  Make sure the printer is powered on and connected.
+                </p>
+                <button
+                  onClick={refresh}
+                  className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
+                >
+                  Retry
+                </button>
+              </div>
+            </div>
+          )}
+          <img
+            ref={imgRef}
+            key={imageKey}
+            src={currentUrl}
+            alt="Camera stream"
+            className="max-w-full max-h-full object-contain"
+            onError={currentUrl ? handleStreamError : undefined}
+            onLoad={currentUrl ? handleStreamLoad : undefined}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}

File diff suppressed because it is too large
+ 745 - 186
frontend/src/pages/PrintersPage.tsx


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BkIG8nsB.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DzQp7Kty.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-SGO6jLr2.css


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-B5zVKOmX.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BkIG8nsB.css">
+    <script type="module" crossorigin src="/assets/index-DzQp7Kty.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-SGO6jLr2.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff