Browse Source

- Fix AMS filament matching in reprint modal

  - Add fuzzy color matching for filament comparison
    Colors within RGB threshold of 40 are considered a match, fixing
    cases where 3MF and printer report slightly different hex values
    for the same filament color (e.g., beige #F5F5DC vs #FCECD6)

  - Add POST /printers/{id}/refresh-status endpoint
    Sends MQTT pushall command to request fresh AMS data from printer

  - Improve Re-read button in filament check section
    - Now triggers backend refresh before refetching status
    - More prominent styling with text label and border
    - 500ms delay after pushall to allow printer to respond

  - Add AMS slot labels to matched filament display
    Shows location like "PLA (AMS-B Slot 3)" instead of just "PLA"

  - Add color hex tooltips on filament color circles
maziggy 5 months ago
parent
commit
3cba1c0989

+ 15 - 0
backend/app/api/routes/printers.py

@@ -334,6 +334,21 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
     )
     )
 
 
 
 
+@router.post("/{printer_id}/refresh-status")
+async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Request a full status refresh from the printer (sends pushall command)."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    success = printer_manager.request_status_update(printer_id)
+    if not success:
+        raise HTTPException(400, "Printer not connected")
+
+    return {"status": "refresh_requested"}
+
+
 @router.post("/{printer_id}/connect")
 @router.post("/{printer_id}/connect")
 async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
 async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
     """Manually connect to a printer."""
     """Manually connect to a printer."""

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

@@ -1253,6 +1253,10 @@ export const api = {
     ),
     ),
   getPrinterStatus: (id: number) =>
   getPrinterStatus: (id: number) =>
     request<PrinterStatus>(`/printers/${id}/status`),
     request<PrinterStatus>(`/printers/${id}/status`),
+  refreshPrinterStatus: (id: number) =>
+    request<{ status: string }>(`/printers/${id}/refresh-status`, {
+      method: 'POST',
+    }),
   connectPrinter: (id: number) =>
   connectPrinter: (id: number) =>
     request<{ connected: boolean }>(`/printers/${id}/connect`, {
     request<{ connected: boolean }>(`/printers/${id}/connect`, {
       method: 'POST',
       method: 'POST',

+ 144 - 86
frontend/src/components/ReprintModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useState, useEffect, useMemo } from 'react';
-import { useQuery, useMutation } from '@tanstack/react-query';
-import { X, Printer, Loader2, AlertTriangle, Check, Circle } from 'lucide-react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -13,7 +13,9 @@ interface ReprintModalProps {
 }
 }
 
 
 export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
 export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
+  const queryClient = useQueryClient();
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
+  const [isRefreshing, setIsRefreshing] = useState(false);
 
 
   // Close on Escape key
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
@@ -55,93 +57,126 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
 
 
   const activePrinters = printers?.filter((p) => p.is_active) || [];
   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 }>();
+  // 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}`;
+  };
 
 
-    // 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 format slot label for display
+  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
+    if (isExternal) return 'External';
+    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId)); // A, B, C, D
+    if (isHt) return `HT-${letter}`;
+    return `AMS-${letter} Slot ${trayId + 1}`;
+  };
 
 
-    // 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),
-          });
-        }
-      });
-    });
+  // Build a list of all loaded filaments from printer's AMS/HT/External with location info
+  const loadedFilaments = useMemo(() => {
+    const filaments: Array<{
+      type: string;
+      color: string;
+      amsId: number;
+      trayId: number;
+      isHt: boolean;
+      isExternal: boolean;
+      label: string;
+    }> = [];
 
 
-    // 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) => {
+    // Add filaments from all AMS units (regular and HT)
+    printerStatus?.ams?.forEach((amsUnit) => {
+      const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
       amsUnit.tray.forEach((tray) => {
       amsUnit.tray.forEach((tray) => {
         if (tray.tray_type) {
         if (tray.tray_type) {
-          map.set(htSlotBase + tray.id, {
+          filaments.push({
             type: tray.tray_type,
             type: tray.tray_type,
             color: normalizeColor(tray.tray_color),
             color: normalizeColor(tray.tray_color),
+            amsId: amsUnit.id,
+            trayId: tray.id,
+            isHt,
+            isExternal: false,
+            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           });
           });
         }
         }
       });
       });
-      htSlotBase += amsUnit.tray.length;
     });
     });
 
 
-    // Add virtual tray (external spool) as slot 254 (Bambu standard)
-    if (printerStatus.vt_tray?.tray_type) {
-      map.set(254, {
+    // Add external spool if loaded
+    if (printerStatus?.vt_tray?.tray_type) {
+      filaments.push({
         type: printerStatus.vt_tray.tray_type,
         type: printerStatus.vt_tray.tray_type,
         color: normalizeColor(printerStatus.vt_tray.tray_color),
         color: normalizeColor(printerStatus.vt_tray.tray_color),
+        amsId: -1,
+        trayId: 0,
+        isHt: false,
+        isExternal: true,
+        label: 'External',
       });
       });
     }
     }
-    return map;
+
+    return filaments;
   }, [printerStatus]);
   }, [printerStatus]);
 
 
   // Compare required filaments with loaded filaments
   // Compare required filaments with loaded filaments
+  // Match by filament TYPE (not slot), since the printer dynamically maps slots
   const filamentComparison = useMemo(() => {
   const filamentComparison = useMemo(() => {
     if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
     if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
 
 
     // Helper to normalize color for comparison (case-insensitive, strip #)
     // Helper to normalize color for comparison (case-insensitive, strip #)
     const normalizeColorForCompare = (color: string | undefined): string => {
     const normalizeColorForCompare = (color: string | undefined): string => {
       if (!color) return '';
       if (!color) return '';
-      return color.replace('#', '').toLowerCase();
+      return color.replace('#', '').toLowerCase().substring(0, 6); // Strip alpha
+    };
+
+    // Helper to check if two colors are similar (within threshold)
+    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
+      const hex1 = normalizeColorForCompare(color1);
+      const hex2 = normalizeColorForCompare(color2);
+      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
+
+      const r1 = parseInt(hex1.substring(0, 2), 16);
+      const g1 = parseInt(hex1.substring(2, 4), 16);
+      const b1 = parseInt(hex1.substring(4, 6), 16);
+      const r2 = parseInt(hex2.substring(0, 2), 16);
+      const g2 = parseInt(hex2.substring(2, 4), 16);
+      const b2 = parseInt(hex2.substring(4, 6), 16);
+
+      // Check if each RGB component is within threshold
+      return Math.abs(r1 - r2) <= threshold &&
+             Math.abs(g1 - g2) <= threshold &&
+             Math.abs(b1 - b2) <= threshold;
     };
     };
 
 
     return filamentReqs.filaments.map((req) => {
     return filamentReqs.filaments.map((req) => {
-      const loaded = loadedFilaments.get(req.slot_id);
+      // Find a loaded filament that matches by TYPE (printer will auto-map the slot)
+      // Priority: exact color match > similar color match > type-only match
+      const exactMatch = loadedFilaments.find(
+        (f) => f.type?.toUpperCase() === req.type?.toUpperCase() &&
+               normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+      );
+      const similarMatch = !exactMatch && loadedFilaments.find(
+        (f) => f.type?.toUpperCase() === req.type?.toUpperCase() &&
+               colorsAreSimilar(f.color, req.color)
+      );
+      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
+        (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+      );
+      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+
       const hasFilament = !!loaded;
       const hasFilament = !!loaded;
-      const typeMatch = hasFilament && loaded?.type?.toUpperCase() === req.type?.toUpperCase();
-      const colorMatch = hasFilament && normalizeColorForCompare(loaded?.color) === normalizeColorForCompare(req.color);
+      const typeMatch = hasFilament;
+      const colorMatch = !!exactMatch || !!similarMatch;
 
 
-      // Status: match (both), type_only (type ok, color different), mismatch (type wrong), empty
+      // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
       let status: 'match' | 'type_only' | 'mismatch' | 'empty';
       let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-      if (!hasFilament) {
-        status = 'empty';
-      } else if (typeMatch && colorMatch) {
+      if (exactMatch || similarMatch) {
         status = 'match';
         status = 'match';
-      } else if (typeMatch) {
-        status = 'type_only'; // Same type, different color
+      } else if (typeOnlyMatch) {
+        status = 'type_only';
       } else {
       } else {
-        status = 'mismatch'; // Different type
+        status = 'mismatch';
       }
       }
 
 
       return {
       return {
@@ -155,8 +190,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     });
     });
   }, [filamentReqs, loadedFilaments]);
   }, [filamentReqs, loadedFilaments]);
 
 
-  const hasAnyMismatch = filamentComparison.some((f) => f.status !== 'match');
-  const hasEmptySlots = filamentComparison.some((f) => f.status === 'empty');
+  const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
 
 
   return (
   return (
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
@@ -226,15 +260,31 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
             <div className="mb-4">
             <div className="mb-4">
               <div className="flex items-center gap-2 mb-2">
               <div className="flex items-center gap-2 mb-2">
                 <span className="text-sm text-bambu-gray">Filament Check</span>
                 <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') ? (
+                <button
+                  onClick={async () => {
+                    if (!selectedPrinter) return;
+                    setIsRefreshing(true);
+                    try {
+                      // Request fresh data from printer via MQTT pushall command
+                      await api.refreshPrinterStatus(selectedPrinter);
+                      // Wait a moment for printer to respond, then refetch
+                      await new Promise((r) => setTimeout(r, 500));
+                      await queryClient.refetchQueries({ queryKey: ['printer-status', selectedPrinter] });
+                    } finally {
+                      setIsRefreshing(false);
+                    }
+                  }}
+                  className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                  title="Re-read AMS status from printer"
+                  disabled={isRefreshing}
+                >
+                  <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+                  <span>Re-read</span>
+                </button>
+                {hasTypeMismatch ? (
                   <span className="text-xs text-orange-400 flex items-center gap-1">
                   <span className="text-xs text-orange-400 flex items-center gap-1">
                     <AlertTriangle className="w-3 h-3" />
                     <AlertTriangle className="w-3 h-3" />
-                    Type mismatch
+                    Type not found
                   </span>
                   </span>
                 ) : filamentComparison.some((f) => f.status === 'type_only') ? (
                 ) : filamentComparison.some((f) => f.status === 'type_only') ? (
                   <span className="text-xs text-yellow-400 flex items-center gap-1">
                   <span className="text-xs text-yellow-400 flex items-center gap-1">
@@ -249,20 +299,20 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
                 )}
                 )}
               </div>
               </div>
               <div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
               <div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
-                {filamentComparison.map((item) => (
+                {filamentComparison.map((item, idx) => (
                   <div
                   <div
-                    key={item.slot_id}
+                    key={idx}
                     className="grid items-center gap-2"
                     className="grid items-center gap-2"
-                    style={{ gridTemplateColumns: '48px 16px 1fr auto 16px 56px 16px' }}
+                    style={{ gridTemplateColumns: '16px 1fr auto 16px 1fr 16px' }}
                   >
                   >
-                    {/* Slot label */}
-                    <span className="text-bambu-gray">Slot {item.slot_id}</span>
                     {/* Required color */}
                     {/* Required color */}
-                    <Circle
-                      className="w-3 h-3 flex-shrink-0"
-                      fill={item.color}
-                      stroke={item.color}
-                    />
+                    <span title={`Required: ${item.color}`}>
+                      <Circle
+                        className="w-3 h-3 flex-shrink-0"
+                        fill={item.color}
+                        stroke={item.color}
+                      />
+                    </span>
                     {/* Required type + grams */}
                     {/* Required type + grams */}
                     <span className="text-white truncate">
                     <span className="text-white truncate">
                       {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
                       {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
@@ -271,38 +321,46 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
                     <span className="text-bambu-gray">→</span>
                     <span className="text-bambu-gray">→</span>
                     {/* Loaded color */}
                     {/* Loaded color */}
                     {item.loaded ? (
                     {item.loaded ? (
-                      <Circle
-                        className="w-3 h-3 flex-shrink-0"
-                        fill={item.loaded.color}
-                        stroke={item.loaded.color}
-                      />
+                      <span title={`Loaded: ${item.loaded.color}`}>
+                        <Circle
+                          className="w-3 h-3 flex-shrink-0"
+                          fill={item.loaded.color}
+                          stroke={item.loaded.color}
+                        />
+                      </span>
                     ) : (
                     ) : (
                       <span />
                       <span />
                     )}
                     )}
-                    {/* Loaded type */}
+                    {/* Loaded type + slot */}
                     <span className={
                     <span className={
                       item.status === 'match' ? 'text-bambu-green' :
                       item.status === 'match' ? 'text-bambu-green' :
                       item.status === 'type_only' ? 'text-yellow-400' :
                       item.status === 'type_only' ? 'text-yellow-400' :
                       'text-orange-400'
                       'text-orange-400'
                     }>
                     }>
-                      {item.loaded?.type || 'Empty'}
+                      {item.loaded ? (
+                        <>{item.loaded.type} <span className="text-bambu-gray">({item.loaded.label})</span></>
+                      ) : (
+                        'Not loaded'
+                      )}
                     </span>
                     </span>
                     {/* Status icon */}
                     {/* Status icon */}
                     {item.status === 'match' ? (
                     {item.status === 'match' ? (
                       <Check className="w-3 h-3 text-bambu-green" />
                       <Check className="w-3 h-3 text-bambu-green" />
                     ) : item.status === 'type_only' ? (
                     ) : item.status === 'type_only' ? (
-                      <span title="Color mismatch">
+                      <span title="Same type, different color">
                         <AlertTriangle className="w-3 h-3 text-yellow-400" />
                         <AlertTriangle className="w-3 h-3 text-yellow-400" />
                       </span>
                       </span>
                     ) : (
                     ) : (
-                      <AlertTriangle className="w-3 h-3 text-orange-400" />
+                      <span title="Filament type not loaded">
+                        <AlertTriangle className="w-3 h-3 text-orange-400" />
+                      </span>
                     )}
                     )}
                   </div>
                   </div>
                 ))}
                 ))}
               </div>
               </div>
-              {(hasAnyMismatch || hasEmptySlots) && (
+              {hasTypeMismatch && (
                 <p className="text-xs text-orange-400 mt-2">
                 <p className="text-xs text-orange-400 mt-2">
-                  The printer may load different filaments than expected.
+                  Required filament type not found in printer.
                 </p>
                 </p>
               )}
               )}
             </div>
             </div>

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-303ohz8i.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CiDcmh7W.css">
+    <script type="module" crossorigin src="/assets/index-Bv0AILUD.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BOEy1ke3.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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