Browse Source

Add manual AMS slot selection for prints (#76)

- ReprintModal: Dropdown to override auto-matched AMS slots
- AddToQueueModal: Collapsible filament mapping section
- Backend: Store ams_mapping in print queue, apply when print starts
- Color names in dropdowns (decoded from Bambu codes or derived from hex)
- Blue ring indicator for manually selected vs auto-matched slots
- Status indicators: green (match), yellow (type only), orange (not found)

Closes #76

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
maziggy 4 months ago
parent
commit
ad4e4fc3a2

+ 12 - 0
CHANGELOG.md

@@ -2,6 +2,18 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b10] - 2026-01-11
+
+### Added
+- **AMS color mapping** - Manually select which AMS slot to use for each filament:
+  - ReprintModal: Click dropdown to override auto-matched slots
+  - AddToQueueModal: Collapsible filament mapping section with slot selection
+  - Mapping stored with queued prints and used when print starts
+  - Blue ring indicator shows manually selected slots vs auto-matched
+  - Status indicators: green (match), yellow (type only), orange (not found)
+  - Re-read button to refresh AMS status if spools were swapped
+  - Color names shown in dropdowns (decoded from Bambu filament codes or derived from hex)
+
 ## [0.1.6b9] - 2026-01-09
 
 ### Added

+ 8 - 0
backend/app/api/routes/print_queue.py

@@ -1,5 +1,6 @@
 """API routes for print queue management."""
 
+import json
 import logging
 from datetime import datetime
 
@@ -27,6 +28,12 @@ router = APIRouter(prefix="/queue", tags=["queue"])
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer info to response."""
     response = PrintQueueItemResponse.model_validate(item)
+    # Parse ams_mapping from JSON string
+    if item.ams_mapping:
+        try:
+            response.ams_mapping = json.loads(item.ams_mapping)
+        except json.JSONDecodeError:
+            response.ams_mapping = None
     if item.archive:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
@@ -90,6 +97,7 @@ async def add_to_queue(
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
         manual_start=data.manual_start,
+        ams_mapping=json.dumps(data.ams_mapping) if data.ams_mapping else None,
         position=max_pos + 1,
         status="pending",
     )

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

@@ -393,6 +393,12 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
+    try:
+        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 4 - 0
backend/app/models/print_queue.py

@@ -29,6 +29,10 @@ class PrintQueueItem(Base):
     # Power management
     auto_off_after: Mapped[bool] = mapped_column(Boolean, default=False)  # Power off printer after print
 
+    # AMS mapping: JSON array of global tray IDs for each filament slot
+    # Format: "[5, -1, 2, -1]" where position = slot_id-1, value = global tray ID (-1 = unused)
+    ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 

+ 4 - 0
backend/app/schemas/print_queue.py

@@ -22,6 +22,9 @@ class PrintQueueItemCreate(BaseModel):
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     manual_start: bool = False  # Requires manual trigger to start (staged)
+    # AMS mapping: list of global tray IDs for each filament slot
+    # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
+    ams_mapping: list[int] | None = None
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -42,6 +45,7 @@ class PrintQueueItemResponse(BaseModel):
     require_previous_success: bool
     auto_off_after: bool
     manual_start: bool
+    ams_mapping: list[int] | None = None
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_at: UTCDatetime
     completed_at: UTCDatetime

+ 16 - 2
backend/app/services/print_scheduler.py

@@ -315,8 +315,22 @@ class PrintScheduler:
 
         register_expected_print(item.printer_id, remote_filename, archive.id)
 
-        # Start the print
-        started = printer_manager.start_print(item.printer_id, remote_filename)
+        # Parse AMS mapping if stored
+        ams_mapping = None
+        if item.ams_mapping:
+            try:
+                import json
+
+                ams_mapping = json.loads(item.ams_mapping)
+            except json.JSONDecodeError:
+                logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
+
+        # Start the print with AMS mapping if available
+        started = printer_manager.start_print(
+            item.printer_id,
+            remote_filename,
+            ams_mapping=ams_mapping,
+        )
 
         if started:
             item.status = "printing"

+ 2 - 2
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v17';
-const STATIC_CACHE = 'bambuddy-static-v17';
+const CACHE_NAME = 'bambuddy-v19';
+const STATIC_CACHE = 'bambuddy-static-v19';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

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

@@ -796,6 +796,7 @@ export interface PrintQueueItem {
   require_previous_success: boolean;
   auto_off_after: boolean;
   manual_start: boolean;  // Requires manual trigger to start (staged)
+  ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   started_at: string | null;
   completed_at: string | null;
@@ -814,6 +815,7 @@ export interface PrintQueueItemCreate {
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   manual_start?: boolean;  // Requires manual trigger to start (staged)
+  ams_mapping?: number[] | null;  // AMS slot mapping for multi-color prints
 }
 
 export interface PrintQueueItemUpdate {

+ 420 - 3
frontend/src/components/AddToQueueModal.tsx

@@ -1,12 +1,88 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power, Hand } from 'lucide-react';
+import { Calendar, Clock, X, AlertCircle, Power, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
 import { api } from '../api/client';
 import type { PrintQueueItemCreate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 
+// Bambu Lab filament color mapping by tray_id_name (subset of most common)
+const BAMBU_COLORS: Record<string, string> = {
+  'A00-W1': 'Jade White', 'A00-Y2': 'Sunflower Yellow', 'A00-R0': 'Red', 'A00-K0': 'Black',
+  'A00-G1': 'Bambu Green', 'A00-B3': 'Cobalt Blue', 'A00-D0': 'Gray', 'A00-D3': 'Dark Gray',
+  'A01-W2': 'Ivory White', 'A01-R1': 'Scarlet Red', 'A01-G1': 'Grass Green', 'A01-B3': 'Marine Blue',
+  'G02-W0': 'White', 'G02-K0': 'Black', 'G02-R0': 'Red', 'G02-D0': 'Gray', 'G02-B0': 'Blue',
+  'B00-W0': 'White', 'B00-K0': 'Black', 'B00-R0': 'Red',
+};
+
+// Fallback color codes
+const COLOR_CODE_FALLBACK: Record<string, string> = {
+  'W0': 'White', 'W1': 'Jade White', 'K0': 'Black', 'R0': 'Red', 'B0': 'Blue',
+  'G0': 'Green', 'G1': 'Green', 'Y0': 'Yellow', 'Y2': 'Yellow', 'D0': 'Gray',
+  'D1': 'Silver', 'D3': 'Dark Gray', 'A0': 'Orange', 'P0': 'Purple', 'N0': 'Brown',
+};
+
+// Get color name from Bambu tray_id_name or hex
+function getColorName(trayIdName: string | null | undefined, hexColor: string): string {
+  // Try exact Bambu lookup first
+  if (trayIdName && BAMBU_COLORS[trayIdName]) {
+    return BAMBU_COLORS[trayIdName];
+  }
+  // Try color code fallback (e.g., "A00-Y2" -> "Y2")
+  if (trayIdName) {
+    const parts = trayIdName.split('-');
+    if (parts.length >= 2 && COLOR_CODE_FALLBACK[parts[1]]) {
+      return COLOR_CODE_FALLBACK[parts[1]];
+    }
+  }
+  // Fall back to hex-based name
+  return hexToColorName(hexColor);
+}
+
+// Convert hex color to basic color name using HSL
+function hexToColorName(hex: string | null | undefined): string {
+  if (!hex || hex.length < 6) return 'Unknown';
+  const cleanHex = hex.replace('#', '');
+  const r = parseInt(cleanHex.substring(0, 2), 16);
+  const g = parseInt(cleanHex.substring(2, 4), 16);
+  const b = parseInt(cleanHex.substring(4, 6), 16);
+
+  const max = Math.max(r, g, b) / 255;
+  const min = Math.min(r, g, b) / 255;
+  const l = (max + min) / 2;
+
+  let h = 0;
+  let s = 0;
+
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+    const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;
+    if (max === rNorm) h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
+    else if (max === gNorm) h = ((bNorm - rNorm) / d + 2) / 6;
+    else h = ((rNorm - gNorm) / d + 4) / 6;
+  }
+  h = h * 360;
+
+  if (l < 0.15) return 'Black';
+  if (l > 0.85) return 'White';
+  if (s < 0.15) {
+    if (l < 0.4) return 'Dark Gray';
+    if (l > 0.6) return 'Light Gray';
+    return 'Gray';
+  }
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 45) return 'Orange';
+  if (h < 70) return 'Yellow';
+  if (h < 150) return 'Green';
+  if (h < 200) return 'Cyan';
+  if (h < 260) return 'Blue';
+  if (h < 290) return 'Purple';
+  if (h < 345) return 'Pink';
+  return 'Unknown';
+}
+
 interface AddToQueueModalProps {
   archiveId: number;
   archiveName: string;
@@ -22,12 +98,29 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
   const [scheduledTime, setScheduledTime] = useState('');
   const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
   const [autoOffAfter, setAutoOffAfter] = useState(false);
+  const [showFilamentMapping, setShowFilamentMapping] = useState(false);
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
 
   const { data: printers } = useQuery({
     queryKey: ['printers'],
     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', printerId],
+    queryFn: () => api.getPrinterStatus(printerId!),
+    enabled: !!printerId,
+  });
+
   // Set default printer if only one available
   useEffect(() => {
     if (printers?.length === 1 && !printerId) {
@@ -35,6 +128,11 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
     }
   }, [printers, printerId]);
 
+  // Clear manual mappings when printer changes
+  useEffect(() => {
+    setManualMappings({});
+  }, [printerId]);
+
   // Close on Escape key
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -44,6 +142,207 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
 
+  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
+  const normalizeColor = (color: string | null | undefined): string => {
+    if (!color) return '#808080';
+    const hex = color.replace('#', '').substring(0, 6);
+    return `#${hex}`;
+  };
+
+  // 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));
+    if (isHt) return `HT-${letter}`;
+    return `AMS-${letter} Slot ${trayId + 1}`;
+  };
+
+  // Calculate global tray ID for MQTT command
+  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
+    if (isExternal) return 254;
+    return amsId * 4 + trayId;
+  };
+
+  // Build a list of all loaded filaments from printer's AMS/HT/External
+  const loadedFilaments = useMemo(() => {
+    const filaments: Array<{
+      type: string;
+      color: string;
+      colorName: string;
+      amsId: number;
+      trayId: number;
+      isHt: boolean;
+      isExternal: boolean;
+      label: string;
+      globalTrayId: number;
+    }> = [];
+
+    printerStatus?.ams?.forEach((amsUnit) => {
+      const isHt = amsUnit.tray.length === 1;
+      amsUnit.tray.forEach((tray) => {
+        if (tray.tray_type) {
+          const color = normalizeColor(tray.tray_color);
+          filaments.push({
+            type: tray.tray_type,
+            color,
+            colorName: getColorName(tray.tray_id_name, color),
+            amsId: amsUnit.id,
+            trayId: tray.id,
+            isHt,
+            isExternal: false,
+            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          });
+        }
+      });
+    });
+
+    if (printerStatus?.vt_tray?.tray_type) {
+      const color = normalizeColor(printerStatus.vt_tray.tray_color);
+      filaments.push({
+        type: printerStatus.vt_tray.tray_type,
+        color,
+        colorName: getColorName(printerStatus.vt_tray.tray_id_name, color),
+        amsId: -1,
+        trayId: 0,
+        isHt: false,
+        isExternal: true,
+        label: 'External',
+        globalTrayId: 254,
+      });
+    }
+
+    return filaments;
+  }, [printerStatus]);
+
+  // Compare required filaments with loaded filaments
+  const filamentComparison = useMemo(() => {
+    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
+
+    const normalizeColorForCompare = (color: string | undefined): string => {
+      if (!color) return '';
+      return color.replace('#', '').toLowerCase().substring(0, 6);
+    };
+
+    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);
+
+      return Math.abs(r1 - r2) <= threshold &&
+             Math.abs(g1 - g2) <= threshold &&
+             Math.abs(b1 - b2) <= threshold;
+    };
+
+    const usedTrayIds = new Set<number>(Object.values(manualMappings));
+
+    return filamentReqs.filaments.map((req) => {
+      const slotId = req.slot_id || 0;
+
+      // Check if there's a manual override for this slot
+      if (slotId > 0 && manualMappings[slotId] !== undefined) {
+        const manualTrayId = manualMappings[slotId];
+        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+        if (manualLoaded) {
+          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+                            colorsAreSimilar(manualLoaded.color, req.color);
+
+          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+          if (typeMatch && colorMatch) {
+            status = 'match';
+          } else if (typeMatch) {
+            status = 'type_only';
+          } else {
+            status = 'mismatch';
+          }
+
+          return {
+            ...req,
+            loaded: manualLoaded,
+            hasFilament: true,
+            typeMatch,
+            colorMatch,
+            status,
+            isManual: true,
+          };
+        }
+      }
+
+      // Auto-match
+      const exactMatch = loadedFilaments.find(
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase() &&
+               normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+      );
+      const similarMatch = !exactMatch && loadedFilaments.find(
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase() &&
+               colorsAreSimilar(f.color, req.color)
+      );
+      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase()
+      );
+      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+
+      if (loaded) {
+        usedTrayIds.add(loaded.globalTrayId);
+      }
+
+      const hasFilament = !!loaded;
+      const typeMatch = hasFilament;
+      const colorMatch = !!exactMatch || !!similarMatch;
+
+      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+      if (exactMatch || similarMatch) {
+        status = 'match';
+      } else if (typeOnlyMatch) {
+        status = 'type_only';
+      } else {
+        status = 'mismatch';
+      }
+
+      return {
+        ...req,
+        loaded,
+        hasFilament,
+        typeMatch,
+        colorMatch,
+        status,
+        isManual: false,
+      };
+    });
+  }, [filamentReqs, loadedFilaments, manualMappings]);
+
+  // Build AMS mapping array
+  const amsMapping = useMemo(() => {
+    if (filamentComparison.length === 0) return undefined;
+
+    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
+    if (maxSlotId <= 0) return undefined;
+
+    const mapping = new Array(maxSlotId).fill(-1);
+
+    filamentComparison.forEach((f) => {
+      if (f.slot_id && f.slot_id > 0) {
+        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
+      }
+    });
+
+    return mapping;
+  }, [filamentComparison]);
+
+  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
+
   const addMutation = useMutation({
     mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
     onSuccess: () => {
@@ -69,6 +368,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
       require_previous_success: requirePreviousSuccess,
       auto_off_after: autoOffAfter,
       manual_start: scheduleType === 'manual',
+      ams_mapping: amsMapping,
     };
 
     if (scheduleType === 'scheduled' && scheduledTime) {
@@ -90,7 +390,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
       className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
       onClick={onClose}
     >
-      <Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
+      <Card className="w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
         <CardContent className="p-0">
           {/* Header */}
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
@@ -137,6 +437,123 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
               )}
             </div>
 
+            {/* Filament Mapping Section */}
+            {printerId && hasFilamentReqs && (
+              <div>
+                <button
+                  type="button"
+                  onClick={() => setShowFilamentMapping(!showFilamentMapping)}
+                  className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+                >
+                  <Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
+                  <span>Filament Mapping</span>
+                  {filamentComparison.some(f => f.status === 'mismatch') ? (
+                    <span className="text-xs text-orange-400">(Type not found)</span>
+                  ) : filamentComparison.some(f => f.status === 'type_only') ? (
+                    <span className="text-xs text-yellow-400">(Color mismatch)</span>
+                  ) : (
+                    <span className="text-xs text-bambu-green">(Ready)</span>
+                  )}
+                  {showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
+                </button>
+
+                {showFilamentMapping && (
+                  <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+                    <div className="flex items-center justify-between mb-2">
+                      <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
+                      <button
+                        type="button"
+                        onClick={async () => {
+                          if (!printerId) return;
+                          setIsRefreshing(true);
+                          try {
+                            await api.refreshPrinterStatus(printerId);
+                            await new Promise((r) => setTimeout(r, 500));
+                            await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
+                          } 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"
+                        disabled={isRefreshing}
+                      >
+                        <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+                        <span>Re-read</span>
+                      </button>
+                    </div>
+                    {filamentComparison.map((item, idx) => (
+                      <div
+                        key={idx}
+                        className="grid items-center gap-2 text-xs"
+                        style={{ gridTemplateColumns: '16px 1fr auto 1fr 16px' }}
+                      >
+                        <span title={`Required: ${item.color}`}>
+                          <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
+                        </span>
+                        <span className="text-white truncate">
+                          {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
+                        </span>
+                        <span className="text-bambu-gray">→</span>
+                        <select
+                          value={item.loaded?.globalTrayId ?? ''}
+                          onChange={(e) => {
+                            const slotId = item.slot_id || 0;
+                            if (slotId > 0) {
+                              const value = e.target.value;
+                              if (value === '') {
+                                setManualMappings((prev) => {
+                                  const next = { ...prev };
+                                  delete next[slotId];
+                                  return next;
+                                });
+                              } else {
+                                setManualMappings((prev) => ({
+                                  ...prev,
+                                  [slotId]: parseInt(value, 10),
+                                }));
+                              }
+                            }
+                          }}
+                          className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                            item.status === 'match'
+                              ? 'border-bambu-green/50 text-bambu-green'
+                              : item.status === 'type_only'
+                              ? 'border-yellow-400/50 text-yellow-400'
+                              : 'border-orange-400/50 text-orange-400'
+                          } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                          title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+                        >
+                          <option value="" className="bg-bambu-dark text-bambu-gray">
+                            -- Select slot --
+                          </option>
+                          {loadedFilaments.map((f) => (
+                            <option
+                              key={f.globalTrayId}
+                              value={f.globalTrayId}
+                              className="bg-bambu-dark text-white"
+                            >
+                              {f.label}: {f.type} ({f.colorName})
+                            </option>
+                          ))}
+                        </select>
+                        {item.status === 'match' ? (
+                          <Check className="w-3 h-3 text-bambu-green" />
+                        ) : item.status === 'type_only' ? (
+                          <span title="Same type, different color">
+                            <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                          </span>
+                        ) : (
+                          <span title="Filament type not loaded">
+                            <AlertTriangle className="w-3 h-3 text-orange-400" />
+                          </span>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                )}
+              </div>
+            )}
+
             {/* Schedule type */}
             <div>
               <label className="block text-sm text-bambu-gray mb-2">When to print</label>

+ 175 - 31
frontend/src/components/ReprintModal.tsx

@@ -29,12 +29,95 @@ const DEFAULT_PRINT_OPTIONS: PrintOptions = {
   timelapse: false,
 };
 
+// Bambu Lab filament color mapping by tray_id_name (subset of most common)
+const BAMBU_COLORS: Record<string, string> = {
+  'A00-W1': 'Jade White', 'A00-Y2': 'Sunflower Yellow', 'A00-R0': 'Red', 'A00-K0': 'Black',
+  'A00-G1': 'Bambu Green', 'A00-B3': 'Cobalt Blue', 'A00-D0': 'Gray', 'A00-D3': 'Dark Gray',
+  'A01-W2': 'Ivory White', 'A01-R1': 'Scarlet Red', 'A01-G1': 'Grass Green', 'A01-B3': 'Marine Blue',
+  'G02-W0': 'White', 'G02-K0': 'Black', 'G02-R0': 'Red', 'G02-D0': 'Gray', 'G02-B0': 'Blue',
+  'B00-W0': 'White', 'B00-K0': 'Black', 'B00-R0': 'Red',
+};
+
+// Fallback color codes
+const COLOR_CODE_FALLBACK: Record<string, string> = {
+  'W0': 'White', 'W1': 'Jade White', 'K0': 'Black', 'R0': 'Red', 'B0': 'Blue',
+  'G0': 'Green', 'G1': 'Green', 'Y0': 'Yellow', 'Y2': 'Yellow', 'D0': 'Gray',
+  'D1': 'Silver', 'D3': 'Dark Gray', 'A0': 'Orange', 'P0': 'Purple', 'N0': 'Brown',
+};
+
+// Get color name from Bambu tray_id_name or hex
+function getColorName(trayIdName: string | null | undefined, hexColor: string): string {
+  // Try exact Bambu lookup first
+  if (trayIdName && BAMBU_COLORS[trayIdName]) {
+    return BAMBU_COLORS[trayIdName];
+  }
+  // Try color code fallback (e.g., "A00-Y2" -> "Y2")
+  if (trayIdName) {
+    const parts = trayIdName.split('-');
+    if (parts.length >= 2 && COLOR_CODE_FALLBACK[parts[1]]) {
+      return COLOR_CODE_FALLBACK[parts[1]];
+    }
+  }
+  // Fall back to hex-based name
+  return hexToColorName(hexColor);
+}
+
+// Convert hex color to basic color name using HSL
+function hexToColorName(hex: string | null | undefined): string {
+  if (!hex || hex.length < 6) return 'Unknown';
+  const cleanHex = hex.replace('#', '');
+  const r = parseInt(cleanHex.substring(0, 2), 16);
+  const g = parseInt(cleanHex.substring(2, 4), 16);
+  const b = parseInt(cleanHex.substring(4, 6), 16);
+
+  const max = Math.max(r, g, b) / 255;
+  const min = Math.min(r, g, b) / 255;
+  const l = (max + min) / 2;
+
+  let h = 0;
+  let s = 0;
+
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+    const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;
+    if (max === rNorm) h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
+    else if (max === gNorm) h = ((bNorm - rNorm) / d + 2) / 6;
+    else h = ((rNorm - gNorm) / d + 4) / 6;
+  }
+  h = h * 360;
+
+  if (l < 0.15) return 'Black';
+  if (l > 0.85) return 'White';
+  if (s < 0.15) {
+    if (l < 0.4) return 'Dark Gray';
+    if (l > 0.6) return 'Light Gray';
+    return 'Gray';
+  }
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 45) return 'Orange';
+  if (h < 70) return 'Yellow';
+  if (h < 150) return 'Green';
+  if (h < 200) return 'Cyan';
+  if (h < 260) return 'Blue';
+  if (h < 290) return 'Purple';
+  if (h < 345) return 'Pink';
+  return 'Unknown';
+}
+
 export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
   const queryClient = useQueryClient();
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [showOptions, setShowOptions] = useState(false);
   const [printOptions, setPrintOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
+  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
+
+  // Clear manual mappings when printer changes
+  useEffect(() => {
+    setManualMappings({});
+  }, [selectedPrinter]);
 
   // Close on Escape key
   useEffect(() => {
@@ -107,6 +190,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     const filaments: Array<{
       type: string;
       color: string;
+      colorName: string;
       amsId: number;
       trayId: number;
       isHt: boolean;
@@ -120,9 +204,11 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
       const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
       amsUnit.tray.forEach((tray) => {
         if (tray.tray_type) {
+          const color = normalizeColor(tray.tray_color);
           filaments.push({
             type: tray.tray_type,
-            color: normalizeColor(tray.tray_color),
+            color,
+            colorName: getColorName(tray.tray_id_name, color),
             amsId: amsUnit.id,
             trayId: tray.id,
             isHt,
@@ -136,9 +222,11 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
 
     // Add external spool if loaded
     if (printerStatus?.vt_tray?.tray_type) {
+      const color = normalizeColor(printerStatus.vt_tray.tray_color);
       filaments.push({
         type: printerStatus.vt_tray.tray_type,
-        color: normalizeColor(printerStatus.vt_tray.tray_color),
+        color,
+        colorName: getColorName(printerStatus.vt_tray.tray_id_name, color),
         amsId: -1,
         trayId: 0,
         isHt: false,
@@ -153,6 +241,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
 
   // Compare required filaments with loaded filaments
   // Match by filament TYPE (not slot), since the printer dynamically maps slots
+  // Respects manual overrides when set
   const filamentComparison = useMemo(() => {
     if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
 
@@ -182,12 +271,46 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     };
 
     // Track which trays have been assigned to avoid duplicates
-    const usedTrayIds = new Set<number>();
+    // First, mark all manually assigned trays as used
+    const usedTrayIds = new Set<number>(Object.values(manualMappings));
 
     return filamentReqs.filaments.map((req) => {
-      // Find a loaded filament that matches by TYPE (printer will auto-map the slot)
+      const slotId = req.slot_id || 0;
+
+      // Check if there's a manual override for this slot
+      if (slotId > 0 && manualMappings[slotId] !== undefined) {
+        const manualTrayId = manualMappings[slotId];
+        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+        if (manualLoaded) {
+          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+                            colorsAreSimilar(manualLoaded.color, req.color);
+
+          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+          if (typeMatch && colorMatch) {
+            status = 'match';
+          } else if (typeMatch) {
+            status = 'type_only';
+          } else {
+            status = 'mismatch';
+          }
+
+          return {
+            ...req,
+            loaded: manualLoaded,
+            hasFilament: true,
+            typeMatch,
+            colorMatch,
+            status,
+            isManual: true,
+          };
+        }
+      }
+
+      // Auto-match: Find a loaded filament that matches by TYPE
       // Priority: exact color match > similar color match > type-only match
-      // IMPORTANT: Exclude trays that are already assigned to another slot
+      // IMPORTANT: Exclude trays that are already assigned (manually or auto)
       const exactMatch = loadedFilaments.find(
         (f) => !usedTrayIds.has(f.globalTrayId) &&
                f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -230,9 +353,10 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
         typeMatch,
         colorMatch,
         status,
+        isManual: false,
       };
     });
-  }, [filamentReqs, loadedFilaments]);
+  }, [filamentReqs, loadedFilaments, manualMappings]);
 
   // Build AMS mapping from auto-matched filaments
   // Format: array matching 3MF filament slot structure
@@ -371,7 +495,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
                   <div
                     key={idx}
                     className="grid items-center gap-2"
-                    style={{ gridTemplateColumns: '16px 1fr auto 16px 1fr 16px' }}
+                    style={{ gridTemplateColumns: '16px 1fr auto 1fr 16px' }}
                   >
                     {/* Required color */}
                     <span title={`Required: ${item.color}`}>
@@ -387,30 +511,50 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
                     </span>
                     {/* Arrow */}
                     <span className="text-bambu-gray">→</span>
-                    {/* Loaded color */}
-                    {item.loaded ? (
-                      <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 />
-                    )}
-                    {/* Loaded type + slot */}
-                    <span className={
-                      item.status === 'match' ? 'text-bambu-green' :
-                      item.status === 'type_only' ? 'text-yellow-400' :
-                      'text-orange-400'
-                    }>
-                      {item.loaded ? (
-                        <>{item.loaded.type} <span className="text-bambu-gray">({item.loaded.label})</span></>
-                      ) : (
-                        'Not loaded'
-                      )}
-                    </span>
+                    {/* Slot selector dropdown */}
+                    <select
+                      value={item.loaded?.globalTrayId ?? ''}
+                      onChange={(e) => {
+                        const slotId = item.slot_id || 0;
+                        if (slotId > 0) {
+                          const value = e.target.value;
+                          if (value === '') {
+                            // Clear manual override
+                            setManualMappings((prev) => {
+                              const next = { ...prev };
+                              delete next[slotId];
+                              return next;
+                            });
+                          } else {
+                            setManualMappings((prev) => ({
+                              ...prev,
+                              [slotId]: parseInt(value, 10),
+                            }));
+                          }
+                        }
+                      }}
+                      className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                        item.status === 'match'
+                          ? 'border-bambu-green/50 text-bambu-green'
+                          : item.status === 'type_only'
+                          ? 'border-yellow-400/50 text-yellow-400'
+                          : 'border-orange-400/50 text-orange-400'
+                      } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                      title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+                    >
+                      <option value="" className="bg-bambu-dark text-bambu-gray">
+                        -- Select slot --
+                      </option>
+                      {loadedFilaments.map((f) => (
+                        <option
+                          key={f.globalTrayId}
+                          value={f.globalTrayId}
+                          className="bg-bambu-dark text-white"
+                        >
+                          {f.label}: {f.type} ({f.colorName})
+                        </option>
+                      ))}
+                    </select>
                     {/* Status icon */}
                     {item.status === 'match' ? (
                       <Check className="w-3 h-3 text-bambu-green" />

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


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


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


+ 2 - 2
static/index.html

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

+ 2 - 2
static/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v16';
-const STATIC_CACHE = 'bambuddy-static-v16';
+const CACHE_NAME = 'bambuddy-v19';
+const STATIC_CACHE = 'bambuddy-static-v19';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [

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