Browse Source

- Add AMS filament preview for reprints and file type badges
- AMS Filament Preview:
- Add GET /archives/{id}/filament-requirements endpoint to extract
filament data from archived 3MF slice_info.config
- Show filament comparison in reprint modal when printer is selected
- Compare both filament type AND color with visual indicators:
- Green checkmark: Type and color match
- Yellow warning: Same type, different color
- Orange warning: Different type or empty slot
- Handle AMS-HT units (id >= 128) and virtual tray (slot 254)
- Normalize color formats between API (RRGGBBAA) and 3MF (#RRGGBB)

- File Type Badge:
- Archive cards show GCODE (green) or SOURCE (orange) badge
- Helps users identify which files have print data vs source-only
- Badge appears next to printer name in card content area

- Camera Stream Stability:
- Increase ffmpeg read timeout from 10s to 30s
- Add ffmpeg RTSP options: -timeout, -buffer_size, -max_delay
- Add frontend auto-reconnection with exponential backoff (2s-30s)
- Show reconnection UI with countdown and attempt counter

maziggy 5 months ago
parent
commit
b3ef6ccb32

+ 2 - 0
CHANGELOG.md

@@ -6,6 +6,8 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### Added
 - **Timelapse editor** - Edit timelapse videos with trim, speed adjustment (0.25x-4x), and music overlay. Uses FFmpeg for server-side processing with browser-based preview.
+- **AMS filament preview** - Reprint modal shows filament comparison between what the print requires and what's currently loaded in the AMS. Compares both type and color with visual indicators (green=match, yellow=color mismatch, orange=type mismatch).
+- **File type badge** - Archive cards now show GCODE (green) or SOURCE (orange) badge to indicate whether the file is a sliced print-ready file or source-only.
 - **Docker printer discovery** - Subnet scanning for discovering printers when running in Docker with `network_mode: host`. Automatically detects Docker environment and shows subnet input field in Add Printer dialog.
 - **Printer model mapping** - Discovery now shows friendly model names (X1C, H2D, P1S) instead of raw SSDP codes (BL-P001, O1D, C11).
 - **Discovery API tests** - Comprehensive test coverage for discovery endpoints.

+ 1 - 1
README.md

@@ -49,7 +49,7 @@
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music)
-- Re-print to any connected printer
+- Re-print to any connected printer with AMS filament preview
 - Archive comparison (side-by-side diff)
 
 ### 📊 Monitoring & Stats

+ 69 - 0
backend/app/api/routes/archives.py

@@ -1733,6 +1733,75 @@ async def upload_archives_bulk(
     }
 
 
+@router.get("/{archive_id}/filament-requirements")
+async def get_filament_requirements(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get filament requirements from the archived 3MF file.
+
+    Returns the filaments used in this print with their slot IDs, types, colors,
+    and usage amounts. This can be compared with current AMS state before reprinting.
+    """
+    import xml.etree.ElementTree as ET
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "Archive file not found")
+
+    filaments = []
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            # Parse slice_info.config for filament requirements
+            if "Metadata/slice_info.config" in zf.namelist():
+                content = zf.read("Metadata/slice_info.config").decode()
+                root = ET.fromstring(content)
+
+                # Extract filament elements
+                # Format: <filament id="1" type="PLA" color="#FFFFFF" used_g="100" used_m="10" />
+                for filament_elem in root.findall(".//filament"):
+                    filament_id = filament_elem.get("id")
+                    filament_type = filament_elem.get("type", "")
+                    filament_color = filament_elem.get("color", "")
+                    used_g = filament_elem.get("used_g", "0")
+                    used_m = filament_elem.get("used_m", "0")
+
+                    # Only include filaments that are actually used
+                    try:
+                        used_grams = float(used_g)
+                    except (ValueError, TypeError):
+                        used_grams = 0
+
+                    if used_grams > 0 and filament_id:
+                        filaments.append(
+                            {
+                                "slot_id": int(filament_id),
+                                "type": filament_type,
+                                "color": filament_color,
+                                "used_grams": round(used_grams, 1),
+                                "used_meters": float(used_m) if used_m else 0,
+                            }
+                        )
+
+            # Sort by slot ID
+            filaments.sort(key=lambda x: x["slot_id"])
+
+    except Exception as e:
+        logger.warning(f"Failed to parse filament requirements from archive {archive_id}: {e}")
+
+    return {
+        "archive_id": archive_id,
+        "filename": archive.filename,
+        "filaments": filaments,
+    }
+
+
 @router.post("/{archive_id}/reprint")
 async def reprint_archive(
     archive_id: int,

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

@@ -1576,6 +1576,18 @@ export const api = {
     `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  getArchiveFilamentRequirements: (archiveId: number) =>
+    request<{
+      archive_id: number;
+      filename: string;
+      filaments: Array<{
+        slot_id: number;
+        type: string;
+        color: string;
+        used_grams: number;
+        used_meters: number;
+      }>;
+    }>(`/archives/${archiveId}/filament-requirements`),
   reprintArchive: (archiveId: number, printerId: number) =>
     request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
       `/archives/${archiveId}/reprint?printer_id=${printerId}`,

+ 205 - 2
frontend/src/components/ReprintModal.tsx

@@ -1,6 +1,6 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
-import { X, Printer, Loader2 } from 'lucide-react';
+import { X, Printer, Loader2, AlertTriangle, Check, Circle } from 'lucide-react';
 import { api } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -29,6 +29,19 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     queryFn: api.getPrinters,
   });
 
+  // Fetch filament requirements from the archived 3MF
+  const { data: filamentReqs } = useQuery({
+    queryKey: ['archive-filaments', archiveId],
+    queryFn: () => api.getArchiveFilamentRequirements(archiveId),
+  });
+
+  // Fetch printer status when a printer is selected
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printer-status', selectedPrinter],
+    queryFn: () => api.getPrinterStatus(selectedPrinter!),
+    enabled: !!selectedPrinter,
+  });
+
   const reprintMutation = useMutation({
     mutationFn: () => {
       if (!selectedPrinter) throw new Error('No printer selected');
@@ -42,6 +55,109 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
 
   const activePrinters = printers?.filter((p) => p.is_active) || [];
 
+  // Build a map of AMS slot positions to loaded filaments
+  // Bambu Lab slot numbering: slot = amsId * 4 + trayId + 1 (for regular AMS)
+  // AMS-HT (id >= 128) is special - uses its position in the array
+  // External spool: slot 254, No filament: slot 255
+  const loadedFilaments = useMemo(() => {
+    if (!printerStatus?.ams) return new Map<number, { type: string; color: string }>();
+
+    const map = new Map<number, { type: string; color: string }>();
+
+    // Sort AMS units by ID to get consistent ordering, filter out AMS-HT for now
+    const regularAms = printerStatus.ams
+      .filter((ams) => ams.id < 128)
+      .sort((a, b) => a.id - b.id);
+
+    // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
+    const normalizeColor = (color: string | null | undefined): string => {
+      if (!color) return '#808080';
+      // Remove alpha channel if present (8-char hex to 6-char)
+      const hex = color.replace('#', '').substring(0, 6);
+      return `#${hex}`;
+    };
+
+    regularAms.forEach((amsUnit) => {
+      amsUnit.tray.forEach((tray) => {
+        // Calculate global slot ID (1-based to match 3MF)
+        // AMS 0 tray 0 = slot 1, AMS 0 tray 1 = slot 2, etc.
+        const globalSlotId = amsUnit.id * 4 + tray.id + 1;
+        if (tray.tray_type) {
+          map.set(globalSlotId, {
+            type: tray.tray_type,
+            color: normalizeColor(tray.tray_color),
+          });
+        }
+      });
+    });
+
+    // AMS-HT units get slots after regular AMS slots
+    const amsHtUnits = printerStatus.ams.filter((ams) => ams.id >= 128);
+    let htSlotBase = regularAms.length * 4 + 1;
+    amsHtUnits.forEach((amsUnit) => {
+      amsUnit.tray.forEach((tray) => {
+        if (tray.tray_type) {
+          map.set(htSlotBase + tray.id, {
+            type: tray.tray_type,
+            color: normalizeColor(tray.tray_color),
+          });
+        }
+      });
+      htSlotBase += amsUnit.tray.length;
+    });
+
+    // Add virtual tray (external spool) as slot 254 (Bambu standard)
+    if (printerStatus.vt_tray?.tray_type) {
+      map.set(254, {
+        type: printerStatus.vt_tray.tray_type,
+        color: normalizeColor(printerStatus.vt_tray.tray_color),
+      });
+    }
+    return map;
+  }, [printerStatus]);
+
+  // Compare required filaments with loaded filaments
+  const filamentComparison = useMemo(() => {
+    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
+
+    // Helper to normalize color for comparison (case-insensitive, strip #)
+    const normalizeColorForCompare = (color: string | undefined): string => {
+      if (!color) return '';
+      return color.replace('#', '').toLowerCase();
+    };
+
+    return filamentReqs.filaments.map((req) => {
+      const loaded = loadedFilaments.get(req.slot_id);
+      const hasFilament = !!loaded;
+      const typeMatch = hasFilament && loaded?.type?.toUpperCase() === req.type?.toUpperCase();
+      const colorMatch = hasFilament && normalizeColorForCompare(loaded?.color) === normalizeColorForCompare(req.color);
+
+      // Status: match (both), type_only (type ok, color different), mismatch (type wrong), empty
+      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+      if (!hasFilament) {
+        status = 'empty';
+      } else if (typeMatch && colorMatch) {
+        status = 'match';
+      } else if (typeMatch) {
+        status = 'type_only'; // Same type, different color
+      } else {
+        status = 'mismatch'; // Different type
+      }
+
+      return {
+        ...req,
+        loaded,
+        hasFilament,
+        typeMatch,
+        colorMatch,
+        status,
+      };
+    });
+  }, [filamentReqs, loadedFilaments]);
+
+  const hasAnyMismatch = filamentComparison.some((f) => f.status !== 'match');
+  const hasEmptySlots = filamentComparison.some((f) => f.status === 'empty');
+
   return (
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
       <Card className="w-full max-w-md">
@@ -105,6 +221,93 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
             </div>
           )}
 
+          {/* Filament comparison - show when printer selected and has filament requirements */}
+          {selectedPrinter && filamentComparison.length > 0 && (
+            <div className="mb-4">
+              <div className="flex items-center gap-2 mb-2">
+                <span className="text-sm text-bambu-gray">Filament Check</span>
+                {hasEmptySlots ? (
+                  <span className="text-xs text-orange-400 flex items-center gap-1">
+                    <AlertTriangle className="w-3 h-3" />
+                    Empty slots
+                  </span>
+                ) : filamentComparison.some((f) => f.status === 'mismatch') ? (
+                  <span className="text-xs text-orange-400 flex items-center gap-1">
+                    <AlertTriangle className="w-3 h-3" />
+                    Type mismatch
+                  </span>
+                ) : filamentComparison.some((f) => f.status === 'type_only') ? (
+                  <span className="text-xs text-yellow-400 flex items-center gap-1">
+                    <AlertTriangle className="w-3 h-3" />
+                    Color mismatch
+                  </span>
+                ) : (
+                  <span className="text-xs text-bambu-green flex items-center gap-1">
+                    <Check className="w-3 h-3" />
+                    Ready
+                  </span>
+                )}
+              </div>
+              <div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
+                {filamentComparison.map((item) => (
+                  <div
+                    key={item.slot_id}
+                    className="grid items-center gap-2"
+                    style={{ gridTemplateColumns: '48px 16px 1fr auto 16px 56px 16px' }}
+                  >
+                    {/* Slot label */}
+                    <span className="text-bambu-gray">Slot {item.slot_id}</span>
+                    {/* Required color */}
+                    <Circle
+                      className="w-3 h-3 flex-shrink-0"
+                      fill={item.color}
+                      stroke={item.color}
+                    />
+                    {/* Required type + grams */}
+                    <span className="text-white truncate">
+                      {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
+                    </span>
+                    {/* Arrow */}
+                    <span className="text-bambu-gray">→</span>
+                    {/* Loaded color */}
+                    {item.loaded ? (
+                      <Circle
+                        className="w-3 h-3 flex-shrink-0"
+                        fill={item.loaded.color}
+                        stroke={item.loaded.color}
+                      />
+                    ) : (
+                      <span />
+                    )}
+                    {/* Loaded type */}
+                    <span className={
+                      item.status === 'match' ? 'text-bambu-green' :
+                      item.status === 'type_only' ? 'text-yellow-400' :
+                      'text-orange-400'
+                    }>
+                      {item.loaded?.type || 'Empty'}
+                    </span>
+                    {/* Status icon */}
+                    {item.status === 'match' ? (
+                      <Check className="w-3 h-3 text-bambu-green" />
+                    ) : item.status === 'type_only' ? (
+                      <span title="Color mismatch">
+                        <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                      </span>
+                    ) : (
+                      <AlertTriangle className="w-3 h-3 text-orange-400" />
+                    )}
+                  </div>
+                ))}
+              </div>
+              {(hasAnyMismatch || hasEmptySlots) && (
+                <p className="text-xs text-orange-400 mt-2">
+                  The printer may load different filaments than expected.
+                </p>
+              )}
+            </div>
+          )}
+
           {/* Error message */}
           {reprintMutation.isError && (
             <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">

+ 16 - 1
frontend/src/pages/ArchivesPage.tsx

@@ -526,8 +526,23 @@ function ArchiveCard({
         <h3 className="font-medium text-white mb-1 truncate">
           {archive.print_name || archive.filename}
         </h3>
-        <div className="flex items-center gap-2 mb-3">
+        <div className="flex items-center gap-2 mb-3 flex-wrap">
           <p className="text-xs text-bambu-gray">{printerName}</p>
+          {/* File type badge */}
+          <span
+            className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
+              archive.filename?.toLowerCase().includes('.gcode.')
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'bg-orange-500/20 text-orange-400'
+            }`}
+            title={
+              archive.filename?.toLowerCase().includes('.gcode.')
+                ? 'Sliced file - ready to print'
+                : 'Source file only - no AMS mapping available'
+            }
+          >
+            {archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
+          </span>
           {archive.project_name && (
             <span
               className="text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]"

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Br04PEYW.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-D3FBMKgQ.js"></script>
+    <script type="module" crossorigin src="/assets/index-Br04PEYW.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Cwam9OQ3.css">
   </head>
   <body>

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