Przeglądaj źródła

Add print options to reprint modal and fix AMS mapping for multi-color prints

  Features:
  - Add print options UI to reprint modal (bed leveling, flow calibration,
    vibration calibration, first layer inspection, timelapse)
  - Collapsible "Print Options" section with toggle switches
  - Default values: bed leveling=on, vibration_cali=on, others=off

  Fixes:
  - Fix AMS mapping format to match Bambu Studio exactly
    - Use slot_id from 3MF to position tray IDs correctly
    - Fill unused slots with -1 placeholder
    - Add ams_mapping2 with detailed {ams_id, slot_id} pairs
  - Fix print command format for proper printer recognition
    - Add auto_bed_leveling, cfg, extrude_cali_flag, nozzle_offset_cali
    - Use American spelling "bed_leveling" (not British "bed_levelling")
    - Match sequence_id format from official apps
  - Prevent duplicate tray assignment in filament matching

  This resolves the "stuck at filament change" issue on multi-color reprints
  affecting X1C and other multi-AMS printer configurations.
maziggy 4 miesięcy temu
rodzic
commit
000ef632d8

+ 24 - 0
CHANGELOG.md

@@ -2,6 +2,30 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b8] - 2026-01-08
+
+### Added
+- **Reprint modal print options** - Configure print settings when reprinting from archive:
+  - Bed leveling toggle (default: enabled)
+  - Flow calibration toggle (default: disabled)
+  - Vibration calibration toggle (default: enabled)
+  - First layer inspection toggle (default: disabled)
+  - Timelapse recording toggle (default: disabled)
+  - Collapsible "Print Options" section with toggle switches
+  - Settings sent to printer match Bambu Studio's format exactly
+
+### Fixed
+- **AMS mapping for multi-color reprints** - Fixed filament slot mapping for multi-color prints:
+  - AMS mapping now matches Bambu Studio's exact format with `ams_mapping2` detailed structure
+  - Correct handling of 3MF filament slot IDs (1-indexed to 0-indexed conversion)
+  - Unused filament slots properly filled with `-1` placeholder
+  - Prevents duplicate tray assignment when multiple filaments match the same type
+  - Resolves "stuck at filament change" issue on X1C and other multi-AMS printers
+- **Print command format** - Updated MQTT print command to match Bambu Studio exactly:
+  - Added `auto_bed_leveling`, `cfg`, `extrude_cali_flag`, `extrude_cali_manual_mode`, `nozzle_offset_cali` fields
+  - Uses American spelling `bed_leveling` (not British `bed_levelling`)
+  - Proper `sequence_id` format matching official apps
+
 ## [0.1.6b7] - 2026-01-04
 
 ### Added

+ 25 - 4
backend/app/api/routes/archives.py

@@ -12,7 +12,7 @@ from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
-from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate
+from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
 
 logger = logging.getLogger(__name__)
@@ -1974,6 +1974,7 @@ async def get_filament_requirements(
 async def reprint_archive(
     archive_id: int,
     printer_id: int,
+    body: ReprintRequest | None = None,
     db: AsyncSession = Depends(get_db),
 ):
     """Send an archived 3MF file to a printer and start printing."""
@@ -1982,6 +1983,10 @@ async def reprint_archive(
     from backend.app.services.bambu_ftp import upload_file_async
     from backend.app.services.printer_manager import printer_manager
 
+    # Use defaults if no body provided
+    if body is None:
+        body = ReprintRequest()
+
     # Get archive
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2049,10 +2054,26 @@ async def reprint_archive(
     except Exception:
         pass  # Default to plate 1 if detection fails
 
-    logger.info(f"Reprint archive {archive_id}: using plate_id={plate_id}")
+    logger.info(
+        f"Reprint archive {archive_id}: plate_id={plate_id}, "
+        f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}, "
+        f"flow_cali={body.flow_cali}, vibration_cali={body.vibration_cali}, "
+        f"layer_inspect={body.layer_inspect}, timelapse={body.timelapse}"
+    )
 
-    # Start the print
-    started = printer_manager.start_print(printer_id, remote_filename, plate_id)
+    # Start the print with options
+    started = printer_manager.start_print(
+        printer_id,
+        remote_filename,
+        plate_id,
+        ams_mapping=body.ams_mapping,
+        timelapse=body.timelapse,
+        bed_levelling=body.bed_levelling,
+        flow_cali=body.flow_cali,
+        vibration_cali=body.vibration_cali,
+        layer_inspect=body.layer_inspect,
+        use_ams=body.use_ams,
+    )
 
     if not started:
         raise HTTPException(500, "Failed to start print")

+ 16 - 0
backend/app/schemas/archive.py

@@ -152,3 +152,19 @@ class ProjectPageUpdate(BaseModel):
     copyright: str | None = None
     profile_title: str | None = None
     profile_description: str | None = None
+
+
+class ReprintRequest(BaseModel):
+    """Request body for reprinting an archive."""
+
+    # AMS slot mapping: list of tray IDs for each filament slot in the 3MF
+    # Global tray ID = (ams_id * 4) + slot_id, external = 254
+    ams_mapping: list[int] | None = None
+
+    # Print options
+    bed_levelling: bool = True
+    flow_cali: bool = False
+    vibration_cali: bool = True
+    layer_inspect: bool = False
+    timelapse: bool = False
+    use_ams: bool = True  # Not exposed in UI, but needed for API

+ 63 - 11
backend/app/services/bambu_mqtt.py

@@ -1901,29 +1901,81 @@ class BambuMQTTClient:
         self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=15)
         self._client.loop_start()
 
-    def start_print(self, filename: str, plate_id: int = 1):
+    def start_print(
+        self,
+        filename: str,
+        plate_id: int = 1,
+        ams_mapping: list[int] | None = None,
+        bed_levelling: bool = True,
+        flow_cali: bool = False,
+        vibration_cali: bool = True,
+        layer_inspect: bool = False,
+        timelapse: bool = False,
+        use_ams: bool = True,
+    ):
         """Start a print job on the printer.
 
         The file should already be uploaded to /cache/ on the printer via FTP.
+
+        Args:
+            filename: Name of the uploaded file
+            plate_id: Plate number to print (default 1)
+            ams_mapping: List of tray IDs for each filament slot in the 3MF.
+                         Global tray ID = (ams_id * 4) + slot_id, external = 254
+            timelapse: Record timelapse video
+            bed_levelling: Auto bed levelling before print
+            flow_cali: Flow/pressure advance calibration
+            vibration_cali: Vibration compensation calibration
+            layer_inspect: First layer AI inspection
+            use_ams: Use AMS for automatic filament changes
         """
         if self._client and self.state.connected:
-            # Bambu print command format
-            # Based on: https://github.com/darkorb/bambu-ftp-and-print
+            # Bambu print command format - matches Bambu Studio's format
+            # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
+            ams_mapping2 = []
+            if ams_mapping is not None:
+                for tray_id in ams_mapping:
+                    if tray_id == -1 or tray_id == 255:
+                        ams_mapping2.append({"ams_id": 255, "slot_id": 255})
+                    else:
+                        # Global tray ID = (ams_id * 4) + slot_id
+                        ams_id = tray_id // 4
+                        slot_id = tray_id % 4
+                        ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
+
             command = {
                 "print": {
-                    "sequence_id": 0,
+                    "sequence_id": "20000",
                     "command": "project_file",
                     "param": f"Metadata/plate_{plate_id}.gcode",
-                    "subtask_name": filename,
                     "url": f"ftp://{filename}",
-                    "timelapse": False,
-                    "bed_leveling": True,
-                    "flow_cali": True,
-                    "vibration_cali": True,
-                    "layer_inspect": False,
-                    "use_ams": True,
+                    "file": filename,
+                    "md5": "",
+                    "bed_type": "auto",
+                    "timelapse": timelapse,
+                    "bed_leveling": bed_levelling,
+                    "auto_bed_leveling": 1 if bed_levelling else 0,
+                    "flow_cali": flow_cali,
+                    "vibration_cali": vibration_cali,
+                    "layer_inspect": layer_inspect,
+                    "use_ams": use_ams,
+                    "cfg": "0",
+                    "extrude_cali_flag": 0,
+                    "extrude_cali_manual_mode": 0,
+                    "nozzle_offset_cali": 2,
+                    "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
+                    "profile_id": "0",
+                    "project_id": "0",
+                    "subtask_id": "0",
+                    "task_id": "0",
                 }
             }
+
+            # Add AMS mapping if provided
+            if ams_mapping is not None:
+                command["print"]["ams_mapping"] = ams_mapping
+                command["print"]["ams_mapping2"] = ams_mapping2
+
             logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             return True

+ 24 - 2
backend/app/services/printer_manager.py

@@ -160,10 +160,32 @@ class PrinterManager:
                 if self._on_status_change:
                     self._schedule_async(self._on_status_change(printer_id, client.state))
 
-    def start_print(self, printer_id: int, filename: str, plate_id: int = 1) -> bool:
+    def start_print(
+        self,
+        printer_id: int,
+        filename: str,
+        plate_id: int = 1,
+        ams_mapping: list[int] | None = None,
+        bed_levelling: bool = True,
+        flow_cali: bool = False,
+        vibration_cali: bool = True,
+        layer_inspect: bool = False,
+        timelapse: bool = False,
+        use_ams: bool = True,
+    ) -> bool:
         """Start a print on a connected printer."""
         if printer_id in self._clients:
-            return self._clients[printer_id].start_print(filename, plate_id)
+            return self._clients[printer_id].start_print(
+                filename,
+                plate_id,
+                ams_mapping=ams_mapping,
+                timelapse=timelapse,
+                bed_levelling=bed_levelling,
+                flow_cali=flow_cali,
+                vibration_cali=vibration_cali,
+                layer_inspect=layer_inspect,
+                use_ams=use_ams,
+            )
         return False
 
     def stop_print(self, printer_id: int) -> bool:

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

@@ -1707,10 +1707,26 @@ export const api = {
         used_meters: number;
       }>;
     }>(`/archives/${archiveId}/filament-requirements`),
-  reprintArchive: (archiveId: number, printerId: number) =>
+  reprintArchive: (
+    archiveId: number,
+    printerId: number,
+    options?: {
+      ams_mapping?: number[];
+      timelapse?: boolean;
+      bed_levelling?: boolean;
+      flow_cali?: boolean;
+      vibration_cali?: boolean;
+      layer_inspect?: boolean;
+      use_ams?: boolean;
+    }
+  ) =>
     request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
       `/archives/${archiveId}/reprint?printer_id=${printerId}`,
-      { method: 'POST' }
+      {
+        method: 'POST',
+        headers: options ? { 'Content-Type': 'application/json' } : undefined,
+        body: options ? JSON.stringify(options) : undefined,
+      }
     ),
   uploadArchive: async (file: File, printerId?: number): Promise<Archive> => {
     const formData = new FormData();

+ 117 - 5
frontend/src/components/ReprintModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw } from 'lucide-react';
+import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings } from 'lucide-react';
 import { api } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -12,10 +12,29 @@ interface ReprintModalProps {
   onSuccess: () => void;
 }
 
+// Print options with defaults
+interface PrintOptions {
+  timelapse: boolean;
+  bed_levelling: boolean;
+  flow_cali: boolean;
+  vibration_cali: boolean;
+  layer_inspect: boolean;
+}
+
+const DEFAULT_PRINT_OPTIONS: PrintOptions = {
+  bed_levelling: true,
+  flow_cali: false,
+  vibration_cali: true,
+  layer_inspect: false,
+  timelapse: false,
+};
+
 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);
 
   // Close on Escape key
   useEffect(() => {
@@ -47,7 +66,10 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
   const reprintMutation = useMutation({
     mutationFn: () => {
       if (!selectedPrinter) throw new Error('No printer selected');
-      return api.reprintArchive(archiveId, selectedPrinter);
+      return api.reprintArchive(archiveId, selectedPrinter, {
+        ams_mapping: amsMapping,
+        ...printOptions,
+      });
     },
     onSuccess: () => {
       onSuccess();
@@ -73,6 +95,13 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     return `AMS-${letter} Slot ${trayId + 1}`;
   };
 
+  // Calculate global tray ID for MQTT command
+  // Regular AMS: (ams_id * 4) + slot_id, External: 254
+  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 with location info
   const loadedFilaments = useMemo(() => {
     const filaments: Array<{
@@ -83,6 +112,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
       isHt: boolean;
       isExternal: boolean;
       label: string;
+      globalTrayId: number;
     }> = [];
 
     // Add filaments from all AMS units (regular and HT)
@@ -98,6 +128,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
             isHt,
             isExternal: false,
             label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
           });
         }
       });
@@ -113,6 +144,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
         isHt: false,
         isExternal: true,
         label: 'External',
+        globalTrayId: 254,
       });
     }
 
@@ -149,22 +181,34 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
              Math.abs(b1 - b2) <= threshold;
     };
 
+    // Track which trays have been assigned to avoid duplicates
+    const usedTrayIds = new Set<number>();
+
     return filamentReqs.filaments.map((req) => {
       // Find a loaded filament that matches by TYPE (printer will auto-map the slot)
       // Priority: exact color match > similar color match > type-only match
+      // IMPORTANT: Exclude trays that are already assigned to another slot
       const exactMatch = loadedFilaments.find(
-        (f) => f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase() &&
                normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
       );
       const similarMatch = !exactMatch && loadedFilaments.find(
-        (f) => f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase() &&
                colorsAreSimilar(f.color, req.color)
       );
       const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
-        (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
+        (f) => !usedTrayIds.has(f.globalTrayId) &&
+               f.type?.toUpperCase() === req.type?.toUpperCase()
       );
       const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
 
+      // Mark this tray as used so it won't be assigned to another slot
+      if (loaded) {
+        usedTrayIds.add(loaded.globalTrayId);
+      }
+
       const hasFilament = !!loaded;
       const typeMatch = hasFilament;
       const colorMatch = !!exactMatch || !!similarMatch;
@@ -190,6 +234,30 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     });
   }, [filamentReqs, loadedFilaments]);
 
+  // Build AMS mapping from auto-matched filaments
+  // Format: array matching 3MF filament slot structure
+  // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
+  // e.g., slots 1 and 3 used with trays 5 and 2 → [5, -1, 2, -1]
+  const amsMapping = useMemo(() => {
+    if (filamentComparison.length === 0) return undefined;
+
+    // Find the max slot_id to determine array size
+    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
+    if (maxSlotId <= 0) return undefined;
+
+    // Create array with -1 for all positions
+    const mapping = new Array(maxSlotId).fill(-1);
+
+    // Fill in tray IDs at correct positions (slot_id - 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 hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
 
   return (
@@ -366,6 +434,50 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
             </div>
           )}
 
+          {/* Print Options */}
+          {selectedPrinter && (
+            <div className="mb-4">
+              <button
+                onClick={() => setShowOptions(!showOptions)}
+                className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+              >
+                <Settings className="w-4 h-4" />
+                <span>Print Options</span>
+                {showOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
+              </button>
+              {showOptions && (
+                <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+                  {[
+                    { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
+                    { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
+                    { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
+                    { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
+                    { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
+                  ].map(({ key, label, desc }) => (
+                    <label key={key} className="flex items-center justify-between cursor-pointer group">
+                      <div>
+                        <span className="text-sm text-white">{label}</span>
+                        <p className="text-xs text-bambu-gray">{desc}</p>
+                      </div>
+                      <div
+                        className={`relative w-10 h-5 rounded-full transition-colors ${
+                          printOptions[key as keyof PrintOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                        }`}
+                        onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof PrintOptions] }))}
+                      >
+                        <div
+                          className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
+                            printOptions[key as keyof PrintOptions] ? 'translate-x-5' : 'translate-x-0.5'
+                          }`}
+                        />
+                      </div>
+                    </label>
+                  ))}
+                </div>
+              )}
+            </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">

Plik diff jest za duży
+ 0 - 0
static/assets/index-BqMRkLQa.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-CrUIX1oi.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-DIdbNfsf.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-BSqss_Fl.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CrUIX1oi.css">
+    <script type="module" crossorigin src="/assets/index-BqMRkLQa.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DIdbNfsf.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików