Martin Ziegler 9 месяцев назад
Родитель
Сommit
879a68fea0

+ 2 - 5
backend/app/api/routes/printer_control.py

@@ -631,11 +631,8 @@ async def ams_refresh_tray(
     await get_printer_or_404(printer_id, db)
     client = get_mqtt_client_or_503(printer_id)
 
-    success = client.ams_refresh_tray(request.ams_id, request.tray_id)
-    return ControlResponse(
-        success=success,
-        message=f"Refreshing AMS {request.ams_id} tray {request.tray_id}" if success else "Failed to refresh tray"
-    )
+    success, message = client.ams_refresh_tray(request.ams_id, request.tray_id)
+    return ControlResponse(success=success, message=message)
 
 
 # =============================================================================

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

@@ -306,6 +306,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         ams_mapping=ams_mapping,
         ams_extruder_map=ams_extruder_map,
         tray_now=tray_now,
+        ams_status_main=state.ams_status_main,
+        ams_status_sub=state.ams_status_sub,
+        mc_print_sub_stage=state.mc_print_sub_stage,
     )
 
 

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

@@ -48,8 +48,15 @@ class AMSTray(BaseModel):
     id: int
     tray_color: str | None = None
     tray_type: str | None = None
+    tray_sub_brands: str | None = None  # Full name like "PLA Basic", "PETG HF"
+    tray_id_name: str | None = None  # Bambu filament ID like "A00-Y2" (can decode to color)
+    tray_info_idx: str | None = None  # Filament preset ID like "GFA00"
     remain: int = 0
     k: float | None = None  # Pressure advance value
+    tag_uid: str | None = None  # RFID tag UID (any tag)
+    tray_uuid: str | None = None  # Bambu Lab spool UUID (32-char hex)
+    nozzle_temp_min: int | None = None  # Min nozzle temperature
+    nozzle_temp_max: int | None = None  # Max nozzle temperature
 
 
 class AMSUnit(BaseModel):
@@ -127,3 +134,11 @@ class PrinterStatus(BaseModel):
     ams_extruder_map: dict[str, int] = {}
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
+    # AMS status for filament change tracking
+    # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
+    ams_status_main: int = 0
+    # Sub status: specific step within filament change (when main=1)
+    # Known values: 4=retraction, 6=load verification, 7=purge
+    ams_status_sub: int = 0
+    # mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
+    mc_print_sub_stage: int = 0

+ 113 - 19
backend/app/services/bambu_mqtt.py

@@ -116,6 +116,15 @@ class PrinterState:
     tray_now: int = 255
     # Pending load target - used to track what tray we're loading for H2D disambiguation
     pending_tray_target: int | None = None
+    # AMS status for filament change tracking (from print.ams.ams_status field)
+    # ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
+    # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration, etc.
+    ams_status: int = 0
+    ams_status_main: int = 0  # (ams_status >> 8) & 0xFF
+    ams_status_sub: int = 0   # ams_status & 0xFF
+    # mc_print_sub_stage - filament change step indicator from print.mc_print_sub_stage
+    # Used by OrcaSlicer/BambuStudio to track progress during filament load/unload
+    mc_print_sub_stage: int = 0
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -362,6 +371,30 @@ class BambuMQTTClient:
                     logger.info(f"[{self.serial_number}] vt_tray data: {vt_tray}")
                     self._vt_tray_logged = True
 
+            # Parse ams_status directly from print data (NOT from print.ams)
+            # ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
+            # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
+            # Sub status (when main=1): 2=heating, 3=AMS feeding, 4=retract, 6=push, 7=purge
+            if "ams_status" in print_data:
+                raw_ams_status = print_data["ams_status"]
+                if isinstance(raw_ams_status, str):
+                    try:
+                        self.state.ams_status = int(raw_ams_status)
+                    except ValueError:
+                        self.state.ams_status = 0
+                else:
+                    self.state.ams_status = raw_ams_status if raw_ams_status is not None else 0
+
+                # Compute main and sub status
+                self.state.ams_status_sub = self.state.ams_status & 0xFF
+                self.state.ams_status_main = (self.state.ams_status >> 8) & 0xFF
+
+                # Log when ams_status changes (for filament change tracking debug)
+                logger.debug(
+                    f"[{self.serial_number}] ams_status: {self.state.ams_status} "
+                    f"(main={self.state.ams_status_main}, sub={self.state.ams_status_sub})"
+                )
+
             # Check for K-profile response (extrusion_cali)
             if "command" in print_data:
                 logger.debug(f"[{self.serial_number}] Received command response: {print_data.get('command')}")
@@ -590,6 +623,26 @@ class BambuMQTTClient:
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             if non_list_fields:
                 logger.info(f"[{self.serial_number}] AMS dict fields: {non_list_fields}")
+
+            # IMPORTANT: Parse ams_status FIRST before tray_now, so we have fresh status
+            # when checking if we're in filament change mode for tray_now disambiguation
+            if "ams_status" in ams_data:
+                raw_ams_status = ams_data["ams_status"]
+                if isinstance(raw_ams_status, str):
+                    try:
+                        self.state.ams_status = int(raw_ams_status)
+                    except ValueError:
+                        self.state.ams_status = 0
+                else:
+                    self.state.ams_status = raw_ams_status if raw_ams_status is not None else 0
+                # Compute main and sub status
+                self.state.ams_status_sub = self.state.ams_status & 0xFF
+                self.state.ams_status_main = (self.state.ams_status >> 8) & 0xFF
+                logger.debug(
+                    f"[{self.serial_number}] ams_status: {self.state.ams_status} "
+                    f"(main={self.state.ams_status_main}, sub={self.state.ams_status_sub})"
+                )
+
             # Parse tray_now from AMS dict - this is the currently loaded tray global ID
             if "tray_now" in ams_data:
                 raw_tray_now = ams_data["tray_now"]
@@ -628,21 +681,35 @@ class BambuMQTTClient:
                             # Clear pending target since it's stale
                             self.state.pending_tray_target = None
                     else:
-                        # No pending target, use slot number as-is
-                        # This happens when filament was loaded before app started or via printer touchscreen
-                        logger.debug(
-                            f"[{self.serial_number}] H2D tray_now: no pending_tray_target, "
-                            f"using slot {parsed_tray_now} as global ID (may be incorrect for H2D)"
-                        )
-                        self.state.tray_now = parsed_tray_now
+                        # No pending target - check if we already have a resolved global ID
+                        # that matches this slot (from a previous successful disambiguation)
+                        current_tray = self.state.tray_now
+                        if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
+                            # Current tray_now is already a valid global ID that matches this slot
+                            # Keep it (don't overwrite with raw slot number)
+                            logger.debug(
+                                f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
+                                f"(matches incoming slot {parsed_tray_now})"
+                            )
+                        else:
+                            # No match or no existing global ID, use slot number as-is
+                            # This happens when filament was loaded before app started or via printer touchscreen
+                            logger.debug(
+                                f"[{self.serial_number}] H2D tray_now: no pending_tray_target, "
+                                f"using slot {parsed_tray_now} as global ID (may be incorrect for H2D)"
+                            )
+                            self.state.tray_now = parsed_tray_now
                 else:
                     # tray_now > 3 means it's already a global ID, or 255 means unloaded
-                    if parsed_tray_now == 255:
-                        # Filament unloaded - clear pending target
-                        self.state.pending_tray_target = None
+                    # Note: Do NOT clear pending_tray_target on tray_now=255 here.
+                    # During filament change, the printer sends 255 first (unload), then the slot.
+                    # We only clear pending_tray_target explicitly in ams_unload_filament().
                     self.state.tray_now = parsed_tray_now
 
                 logger.debug(f"[{self.serial_number}] tray_now updated: {self.state.tray_now}")
+
+            # NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct
+            # state when checking filament change mode for H2D disambiguation
         elif isinstance(ams_data, list):
             ams_list = ams_data
         else:
@@ -721,6 +788,14 @@ class BambuMQTTClient:
             self.state.progress = float(data["mc_percent"])
         if "mc_remaining_time" in data:
             self.state.remaining_time = int(data["mc_remaining_time"])
+        if "mc_print_sub_stage" in data:
+            new_sub_stage = int(data["mc_print_sub_stage"])
+            if new_sub_stage != self.state.mc_print_sub_stage:
+                logger.debug(
+                    f"[{self.serial_number}] mc_print_sub_stage changed: "
+                    f"{self.state.mc_print_sub_stage} -> {new_sub_stage}"
+                )
+            self.state.mc_print_sub_stage = new_sub_stage
         if "layer_num" in data:
             self.state.layer_num = int(data["layer_num"])
         if "total_layer_num" in data:
@@ -729,10 +804,10 @@ class BambuMQTTClient:
         # Calibration stage tracking
         if "stg_cur" in data:
             new_stg = data["stg_cur"]
+            # Always log ANY stg_cur change for debugging filament operations
             if new_stg != self.state.stg_cur:
                 logger.info(
-                    f"[{self.serial_number}] Calibration stage changed: "
-                    f"{self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
+                    f"[{self.serial_number}] stg_cur changed: {self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
                 )
             self.state.stg_cur = new_stg
         if "stg" in data:
@@ -2314,10 +2389,11 @@ class BambuMQTTClient:
             slot_id = tray_id % 4  # Slot within AMS (0, 1, 2, 3)
 
         # Build command with ams_id and slot_id format (per HA-Bambulab integration)
+        # Note: curr_nozzle is NOT included - ha-bambulab doesn't use it and it may cause issues
         command = {
             "print": {
                 "command": "ams_change_filament",
-                "target": tray_id,  # Keep target for compatibility
+                "target": tray_id,  # Global tray ID (ams_id * 4 + slot_id)
                 "ams_id": ams_id,
                 "slot_id": slot_id,
                 "curr_temp": 220,
@@ -2326,8 +2402,10 @@ class BambuMQTTClient:
             }
         }
 
-        self._client.publish(self.topic_publish, json.dumps(command))
-        logger.info(f"[{self.serial_number}] Loading filament from AMS {ams_id} slot {slot_id} (global tray {tray_id})")
+        command_json = json.dumps(command)
+        logger.info(f"[{self.serial_number}] Publishing ams_change_filament command: {command_json}")
+        self._client.publish(self.topic_publish, command_json)
+        logger.info(f"[{self.serial_number}] Loading filament from AMS {ams_id} slot {slot_id} (global tray {tray_id}) to extruder {curr_nozzle}")
 
         # Track this load request for H2D dual-nozzle disambiguation
         # H2D reports only slot number (0-3) in tray_now, so we use our tracked value
@@ -2392,7 +2470,7 @@ class BambuMQTTClient:
         logger.info(f"[{self.serial_number}] AMS control: {action}")
         return True
 
-    def ams_refresh_tray(self, ams_id: int, tray_id: int) -> bool:
+    def ams_refresh_tray(self, ams_id: int, tray_id: int) -> tuple[bool, str]:
         """Trigger RFID re-read for a specific AMS tray.
 
         Args:
@@ -2400,11 +2478,27 @@ class BambuMQTTClient:
             tray_id: Tray ID within the AMS (0-3)
 
         Returns:
-            True if command was sent, False otherwise
+            Tuple of (success, message)
         """
         if not self._client or not self.state.connected:
             logger.warning(f"[{self.serial_number}] Cannot refresh AMS tray: not connected")
-            return False
+            return False, "Printer not connected"
+
+        # Check if filament is currently loaded (tray_now != 255)
+        # RFID refresh requires the AMS to move filament, which can't happen if one is loaded
+        tray_now = self.state.tray_now
+        if tray_now != 255:
+            # Decode which tray is loaded for the message
+            if tray_now == 254:
+                loaded_tray = "external spool"
+            elif tray_now >= 0 and tray_now < 128:
+                loaded_ams = tray_now // 4
+                loaded_slot = tray_now % 4
+                loaded_tray = f"AMS {loaded_ams + 1} slot {loaded_slot + 1}"
+            else:
+                loaded_tray = f"tray {tray_now}"
+            logger.warning(f"[{self.serial_number}] Cannot refresh AMS tray: filament loaded from {loaded_tray}")
+            return False, f"Please unload filament first. Currently loaded: {loaded_tray}"
 
         # Use ams_get_rfid command to trigger RFID re-read
         # This command is used by Bambu Studio to re-read the RFID tag
@@ -2418,7 +2512,7 @@ class BambuMQTTClient:
         }
         self._client.publish(self.topic_publish, json.dumps(command))
         logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}")
-        return True
+        return True, f"Refreshing AMS {ams_id} tray {tray_id}"
 
     def set_timelapse(self, enable: bool) -> bool:
         """Enable or disable timelapse recording.

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

@@ -140,6 +140,12 @@ export interface PrinterStatus {
   ams_extruder_map: Record<string, number>;
   // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
   tray_now: number;
+  // AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
+  ams_status_main: number;
+  // AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge
+  ams_status_sub: number;
+  // mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
+  mc_print_sub_stage: number;
 }
 
 export interface PrinterCreate {

+ 243 - 91
frontend/src/components/control/AMSSectionDual.tsx

@@ -5,12 +5,8 @@ import type { PrinterStatus, AMSUnit, AMSTray } from '../../api/client';
 import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react';
 import { AMSHumidityModal } from './AMSHumidityModal';
 import { AMSMaterialsModal } from './AMSMaterialsModal';
+import { useToast } from '../../contexts/ToastContext';
 
-// Filament change stages from MQTT stg_cur
-const STAGE_HEATING_NOZZLE = 7;
-const STAGE_FILAMENT_UNLOADING = 22;
-const STAGE_FILAMENT_LOADING = 24;
-const STAGE_CHANGING_FILAMENT = 4;
 
 interface AMSSectionDualProps {
   printerId: number;
@@ -70,7 +66,11 @@ function HumidityIcon({ humidity }: { humidity: number }) {
 // Filament change progress card - appears during load/unload operations
 interface FilamentChangeCardProps {
   isLoading: boolean;  // true = loading, false = unloading
-  currentStage: number;
+  amsStatusMain: number;  // AMS status: 0=idle, 1=filament_change, 2=rfid_identifying, etc.
+  amsStatusSub: number;  // ams_status_sub from MQTT - step within filament change operation
+  trayNow: number;  // Currently loaded tray (255 = none, 254 = external)
+  targetTrayId: number | null;  // Target tray we're trying to load (null for unload)
+  onComplete: () => void;  // Called when operation completes
   onRetry?: () => void;
 }
 
@@ -80,69 +80,137 @@ interface StepInfo {
   stepNumber: number;
 }
 
-function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) {
+function FilamentChangeCard({ isLoading, amsStatusMain, amsStatusSub, trayNow, targetTrayId, onComplete, onRetry }: FilamentChangeCardProps) {
   const [isCollapsed, setIsCollapsed] = useState(false);
-  // Track the highest progress step reached to show proper progression
-  // 0 = initial, 1 = push started, 2 = heating, 3 = purging
-  const [progressStep, setProgressStep] = useState(0);
-  const prevStageRef = useRef(currentStage);
+  const [isCompleted, setIsCompleted] = useState(false);
+  const prevAmsStatusRef = useRef(amsStatusMain);
+  const prevTrayNowRef = useRef(trayNow);
+  const completionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+  // ams_status_sub values for filament change steps
+  // Observed progression: 5 -> 6 -> 2 -> 7 -> 0
+  // 2: Heating nozzle
+  // 3: AMS feeding filament to hub
+  // 4: Retraction / extruder pulling filament
+  // 5: Initial filament push / preparation
+  // 6: Load verification / extruder pushing
+  // 7: Purging
+  const SUB_HEATING = 2;
+  const SUB_FEEDING = 3;
+  const SUB_RETRACT = 4;
+  const SUB_PUSH_PREP = 5;
+  const SUB_PUSH = 6;
+  const SUB_PURGE = 7;
+
+  // Log status updates for debugging
+  useEffect(() => {
+    console.log(`[FilamentChangeCard] Status: main=${amsStatusMain}, sub=${amsStatusSub}, trayNow=${trayNow}, isLoading=${isLoading}`);
+  }, [amsStatusMain, amsStatusSub, trayNow, isLoading]);
 
-  // Update progress based on stage transitions
+  // Detect completion via ams_status_main transition from 1 (filament_change) to 0 (idle)
+  // Also use tray_now as a secondary indicator
   useEffect(() => {
-    // For loading: track progression through stages
-    if (isLoading) {
-      if (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_CHANGING_FILAMENT) {
-        // Push/loading stage - at least step 1
-        if (progressStep < 1) setProgressStep(1);
-      } else if (currentStage === STAGE_HEATING_NOZZLE) {
-        // Heating stage - at least step 2
-        if (progressStep < 2) setProgressStep(2);
-      } else if (progressStep >= 2 && currentStage === STAGE_CHANGING_FILAMENT) {
-        // After heating, back to changing = purge stage
-        setProgressStep(3);
-      }
-    } else {
-      // For unloading: track heating -> unloading
-      if (currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
-        if (progressStep < 1) setProgressStep(1);
-      } else if (currentStage === STAGE_FILAMENT_UNLOADING) {
-        if (progressStep < 2) setProgressStep(2);
-      }
+    const wasActive = prevAmsStatusRef.current === 1;
+    const isNowIdle = amsStatusMain === 0;
+    const trayChanged = trayNow !== prevTrayNowRef.current;
+
+    // Primary completion detection: ams_status_main transitions from 1 to 0
+    if (wasActive && isNowIdle) {
+      console.log(`[FilamentChangeCard] ams_status_main transitioned 1->0, operation complete!`);
+      setIsCompleted(true);
+      // Auto-close after brief delay
+      completionTimeoutRef.current = setTimeout(() => {
+        onComplete();
+      }, 1500);
     }
-
-    // Reset progress when stage returns to idle
-    if (currentStage === -1 && prevStageRef.current !== -1) {
-      // Operation completed, reset for next time
-      setProgressStep(0);
+    // Secondary completion detection: tray_now matches target (for load) or becomes 255 (for unload)
+    else if (trayChanged) {
+      if (isLoading && targetTrayId !== null && trayNow === targetTrayId) {
+        console.log(`[FilamentChangeCard] Load completed! tray_now=${trayNow} matches target=${targetTrayId}`);
+        setIsCompleted(true);
+        completionTimeoutRef.current = setTimeout(() => {
+          onComplete();
+        }, 1500);
+      } else if (!isLoading && trayNow === 255) {
+        console.log(`[FilamentChangeCard] Unload completed! tray_now=255 (no filament)`);
+        setIsCompleted(true);
+        completionTimeoutRef.current = setTimeout(() => {
+          onComplete();
+        }, 1500);
+      }
     }
 
-    prevStageRef.current = currentStage;
-  }, [currentStage, isLoading, progressStep]);
+    prevAmsStatusRef.current = amsStatusMain;
+    prevTrayNowRef.current = trayNow;
+  }, [amsStatusMain, trayNow, isLoading, targetTrayId, onComplete]);
 
-  // Initialize progress to step 1 when card first shows
+  // Cleanup timeout on unmount
   useEffect(() => {
-    if (progressStep === 0) {
-      setProgressStep(1);
-    }
+    return () => {
+      if (completionTimeoutRef.current) {
+        clearTimeout(completionTimeoutRef.current);
+      }
+    };
   }, []);
 
-  // Determine step status based on tracked progress
+  // Determine step based on ams_status_sub from MQTT
+  // ams_status_sub values indicate the current step in filament change:
+  // Load sequence (actual order): 3/6 (push) -> 2 (heating) -> 7 (purging)
+  // Unload sequence: 2 (heating) -> 4 (retracting)
+  const getStepFromAmsStatusSub = (): number => {
+    if (isCompleted) return 99; // All done
+
+    // If not in filament change mode, not started
+    if (amsStatusMain !== 1) return 0;
+
+    if (isLoading) {
+      // Loading sequence: Push -> Heat -> Purge (matches Bambu Studio/OrcaSlicer display)
+      // Observed progression: 5 -> 6 -> 2 -> 7
+      // Map sub status to steps: 5/6 -> step 1, 2 -> step 2, 7 -> step 3
+      if (amsStatusSub === SUB_PUSH_PREP || amsStatusSub === SUB_PUSH || amsStatusSub === SUB_FEEDING) return 1; // Step 1: Pushing
+      if (amsStatusSub === SUB_HEATING) return 2; // Step 2: Heating
+      if (amsStatusSub === SUB_PURGE) return 3; // Step 3: Purging
+      // Default to step 1 when in filament_change mode
+      return 1;
+    } else {
+      // Unloading sequence: Heat -> Retract
+      // Map sub status to steps: 2 -> step 1, 4 -> step 2
+      if (amsStatusSub === SUB_HEATING) return 1; // Step 1: Heating
+      if (amsStatusSub === SUB_RETRACT) return 2; // Step 2: Retracting
+      // Default to step 1 when in filament_change mode
+      return 1;
+    }
+  };
+
+  // Get current step from ams_status_sub
+  const currentStep = getStepFromAmsStatusSub();
+
+  // Debug: log step calculation
+  console.log(`[FilamentChangeCard] Step calculation: currentStep=${currentStep}, amsStatusSub=${amsStatusSub}`);
+
+  // Determine step status based on ams_status_sub
   const getLoadingSteps = (): StepInfo[] => {
-    // Loading sequence: Push filament -> Heat nozzle -> Purge
+    // Loading sequence: Push -> Heat -> Purge (matches Bambu Studio/OrcaSlicer display order)
+    // ams_status_sub progression: 3/6 (push) -> 2 (heating) -> 7 (purging)
     let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
     let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
     let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending';
 
-    if (progressStep >= 3) {
+    if (currentStep >= 99) {
+      // All completed
+      step1Status = 'completed';
+      step2Status = 'completed';
+      step3Status = 'completed';
+    } else if (currentStep >= 3) {
       // Purging - steps 1 & 2 done, step 3 active
       step1Status = 'completed';
       step2Status = 'completed';
       step3Status = 'in_progress';
-    } else if (progressStep >= 2) {
+    } else if (currentStep >= 2) {
       // Heating - step 1 done, step 2 active
       step1Status = 'completed';
       step2Status = 'in_progress';
-    } else if (progressStep >= 1) {
+    } else if (currentStep >= 1) {
       // Pushing - step 1 active
       step1Status = 'in_progress';
     }
@@ -158,11 +226,15 @@ function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChange
     let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
     let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
 
-    if (progressStep >= 2) {
-      // Unloading - step 1 done, step 2 active
+    if (currentStep >= 99) {
+      // All completed
+      step1Status = 'completed';
+      step2Status = 'completed';
+    } else if (currentStep >= 2) {
+      // Retracting - step 1 done, step 2 active
       step1Status = 'completed';
       step2Status = 'in_progress';
-    } else if (progressStep >= 1) {
+    } else if (currentStep >= 1) {
       // Heating - step 1 active
       step1Status = 'in_progress';
     }
@@ -263,6 +335,7 @@ interface AMSPanelContentProps {
   onHumidityClick: (humidity: number, temp: number) => void;
   onSlotRefresh: (amsId: number, slotId: number) => void;
   onEyeClick: (tray: AMSTray, slotLabel: string, amsId: number) => void;
+  refreshingSlot: { amsId: number; trayId: number } | null;
 }
 
 // Panel content - NO wiring, just slots and info
@@ -300,6 +373,7 @@ function AMSPanelContent({
   onHumidityClick,
   onSlotRefresh,
   onEyeClick,
+  refreshingSlot,
 }: AMSPanelContentProps) {
   const selectedUnit = units[selectedAmsIndex];
   const isHT = selectedUnit ? isAmsHT(selectedUnit.id) : false;
@@ -359,14 +433,22 @@ function AMSPanelContent({
           <div className={`flex gap-2 mb-1.5 ${isHT ? 'justify-start pl-2' : 'justify-center'}`}>
             {selectedUnit.tray.map((tray, index) => {
               const slotLabel = getSlotLabel(selectedUnit.id, index);
+              const isRefreshing = refreshingSlot?.amsId === selectedUnit.id && refreshingSlot?.trayId === tray.id;
               return (
                 <button
                   key={tray.id}
                   onClick={() => onSlotRefresh(selectedUnit.id, tray.id)}
-                  className="w-14 flex items-center justify-center gap-0.5 text-[10px] text-bambu-gray px-1.5 py-[3px] bg-bambu-dark rounded-full border border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary transition-colors"
+                  disabled={isRefreshing}
+                  className={`w-14 flex items-center justify-center gap-0.5 text-[10px] text-bambu-gray px-1.5 py-[3px] bg-bambu-dark rounded-full border border-bambu-dark-tertiary transition-colors ${
+                    isRefreshing ? 'opacity-70 cursor-wait' : 'hover:bg-bambu-dark-tertiary'
+                  }`}
                 >
                   {slotLabel}
-                  <img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
+                  <img
+                    src="/icons/reload.svg"
+                    alt=""
+                    className={`w-2.5 h-2.5 icon-theme ${isRefreshing ? 'animate-spin' : ''}`}
+                  />
                 </button>
               );
             })}
@@ -677,6 +759,7 @@ function WiringLayer({
 }
 
 export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }: AMSSectionDualProps) {
+  const { showToast } = useToast();
   const isConnected = status?.connected ?? false;
   const isPrinting = status?.state === 'RUNNING';
   const isDualNozzle = nozzleCount > 1;
@@ -688,26 +771,27 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
 
   // Distribute AMS units based on ams_extruder_map
   // Each AMS unit's info field tells us which extruder it's connected to:
-  // In Bambu slicer convention: extruder 0 = left nozzle, extruder 1 = right nozzle
+  // UI layout: Left panel shows extruder 0 AMS units, Right panel shows extruder 1 AMS units
+  // Note: Internal nozzle IDs are different (T0=right physical nozzle, T1=left physical nozzle)
   const leftUnits = (() => {
     if (!isDualNozzle) return amsUnits;
     if (Object.keys(amsExtruderMap).length > 0) {
-      // Filter AMS units assigned to extruder 0 (left nozzle in slicer convention)
+      // Filter AMS units assigned to extruder 0 (left UI panel)
       // JSON keys are strings, so convert unit.id to string
       return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
     }
-    // Fallback: even indices go to left (extruder 0)
+    // Fallback: even indices go to left
     return amsUnits.filter((_, i) => i % 2 === 0);
   })();
 
   const rightUnits = (() => {
     if (!isDualNozzle) return [];
     if (Object.keys(amsExtruderMap).length > 0) {
-      // Filter AMS units assigned to extruder 1 (right nozzle in slicer convention)
+      // Filter AMS units assigned to extruder 1 (right UI panel)
       // JSON keys are strings, so convert unit.id to string
       return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
     }
-    // Fallback: odd indices go to right (extruder 1)
+    // Fallback: odd indices go to right
     return amsUnits.filter((_, i) => i % 2 === 1);
   })();
 
@@ -719,10 +803,12 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
   const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null);
   const [materialsModal, setMaterialsModal] = useState<{ tray: AMSTray; slotLabel: string; amsId: number } | null>(null);
 
+  // Track refreshing slot - cleared when tray data updates from MQTT
+  const [refreshingSlotState, setRefreshingSlotState] = useState<{ amsId: number; trayId: number; startTime: number } | null>(null);
+
   // Track user-initiated filament change operations (for showing progress card immediately)
-  const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean } | null>(null);
-  // Track the previous stage for detecting when operation completes
-  const prevStageRef = useRef<number>(-1);
+  // Store both the operation type (load/unload) and the target tray ID for load operations
+  const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean; targetTrayId: number | null } | null>(null);
 
   // Track if we've done initial sync from tray_now
   const initialSyncDone = useRef(false);
@@ -747,6 +833,40 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
     }
   }, [status?.tray_now]);
 
+  // Watch for AMS data updates to clear the refresh spinner
+  // When the printer reports updated tray data after RFID read, clear the spinner
+  const prevAmsDataRef = useRef<string>('');
+  useEffect(() => {
+    if (!refreshingSlotState) return;
+
+    // Find the refreshing slot's current data
+    const { amsId, trayId, startTime } = refreshingSlotState;
+    const unit = amsUnits.find(u => u.id === amsId);
+    const tray = unit?.tray?.find(t => t.id === trayId);
+
+    if (!tray) return;
+
+    // Create a signature of the tray data to detect changes
+    const traySignature = JSON.stringify({
+      tag_uid: tray.tag_uid,
+      tray_uuid: tray.tray_uuid,
+      tray_id_name: tray.tray_id_name,
+      tray_type: tray.tray_type,
+      tray_color: tray.tray_color,
+    });
+
+    // If we have previous data and it changed, the refresh is complete
+    // Also require at least 500ms to have passed (to avoid false positives from initial render)
+    const elapsed = Date.now() - startTime;
+    if (prevAmsDataRef.current && prevAmsDataRef.current !== traySignature && elapsed > 500) {
+      console.log(`[AMSSectionDual] RFID refresh complete for AMS ${amsId} tray ${trayId} (took ${elapsed}ms)`);
+      setRefreshingSlotState(null);
+    }
+
+    // Update the ref for next comparison
+    prevAmsDataRef.current = traySignature;
+  }, [refreshingSlotState, amsUnits]);
+
   const loadMutation = useMutation({
     mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) =>
       api.amsLoadFilament(printerId, trayId, extruderId),
@@ -801,17 +921,23 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
       const extruderId = getExtruderIdForTray(selectedTray);
       console.log(`[AMSSectionDual] Calling loadMutation.mutate(tray: ${selectedTray}, extruder: ${extruderId})`);
       // Show filament change card immediately
-      setUserFilamentChange({ isLoading: true });
+      setUserFilamentChange({ isLoading: true, targetTrayId: selectedTray });
       loadMutation.mutate({ trayId: selectedTray, extruderId });
     }
   };
 
   const handleUnload = () => {
-    // Show filament change card immediately
-    setUserFilamentChange({ isLoading: false });
+    // Show filament change card immediately (no target tray for unload)
+    setUserFilamentChange({ isLoading: false, targetTrayId: null });
     unloadMutation.mutate();
   };
 
+  // Callback for FilamentChangeCard to close itself
+  const handleFilamentChangeComplete = () => {
+    console.log(`[AMSSectionDual] FilamentChangeCard completed, closing card`);
+    setUserFilamentChange(null);
+  };
+
   const isLoading = loadMutation.isPending || unloadMutation.isPending;
 
   // Handlers for modals and actions
@@ -823,63 +949,79 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
     mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) =>
       api.refreshAmsTray(printerId, amsId, trayId),
     onSuccess: (data, variables) => {
-      console.log(`[AMSSectionDual] Tray refresh success (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data);
+      console.log(`[AMSSectionDual] Tray refresh response (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data);
+      if (data.success) {
+        showToast(data.message || 'RFID refresh started', 'success');
+      } else {
+        showToast(data.message || 'Failed to refresh tray', 'error');
+      }
     },
     onError: (error, variables) => {
       console.error(`[AMSSectionDual] Tray refresh error (AMS ${variables.amsId}, Tray ${variables.trayId}):`, error);
+      showToast('Failed to refresh tray', 'error');
     },
   });
 
   const handleSlotRefresh = (amsId: number, slotId: number) => {
     // Trigger RFID re-read for the specific tray
     console.log(`[AMSSectionDual] Slot refresh triggered: AMS ${amsId}, Slot ${slotId}, printerId: ${printerId}`);
+    // Reset the previous data ref so we can detect the next change
+    prevAmsDataRef.current = '';
+    // Show spinner immediately - will be cleared when AMS data updates from MQTT
+    const startTime = Date.now();
+    setRefreshingSlotState({ amsId, trayId: slotId, startTime });
     refreshMutation.mutate({ amsId, trayId: slotId });
+    // Fallback timeout (15s) in case data doesn't change (e.g., same spool re-read)
+    setTimeout(() => {
+      setRefreshingSlotState(prev => {
+        if (prev && prev.startTime === startTime) {
+          console.log(`[AMSSectionDual] RFID refresh timeout for AMS ${amsId} tray ${slotId}`);
+          return null;
+        }
+        return prev;
+      });
+    }, 15000);
   };
 
   const handleEyeClick = (tray: AMSTray, slotLabel: string, amsId: number) => {
     setMaterialsModal({ tray, slotLabel, amsId });
   };
 
-  // Determine if we're in a filament change stage (from MQTT)
-  const currentStage = status?.stg_cur ?? -1;
-  const isMqttFilamentChangeActive = [
-    STAGE_HEATING_NOZZLE,
-    STAGE_FILAMENT_UNLOADING,
-    STAGE_FILAMENT_LOADING,
-    STAGE_CHANGING_FILAMENT,
-  ].includes(currentStage);
+  // Determine if we're in a filament change state (from MQTT ams_status)
+  // ams_status_main: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
+  // mc_print_sub_stage: step indicator used by BambuStudio/OrcaSlicer for filament change progress
+  const amsStatusMain = status?.ams_status_main ?? 0;
+  const isMqttFilamentChangeActive = amsStatusMain === 1;
 
   // Auto-close card when operation completes
-  // Track when we transition from an active filament change stage back to idle
+  // Track when we transition from filament change active to idle (ams_status_main 1 -> 0)
+  const prevAmsStatusMainRef = useRef(amsStatusMain);
   useEffect(() => {
-    const wasInFilamentChange = [
-      STAGE_HEATING_NOZZLE,
-      STAGE_FILAMENT_UNLOADING,
-      STAGE_FILAMENT_LOADING,
-      STAGE_CHANGING_FILAMENT,
-    ].includes(prevStageRef.current);
+    const wasActive = prevAmsStatusMainRef.current === 1;
 
     if (isMqttFilamentChangeActive) {
-      // MQTT is now reporting a stage, clear user-triggered state
+      // MQTT is now reporting filament change, clear user-triggered state
       // Card will continue showing because isMqttFilamentChangeActive is true
       setUserFilamentChange(null);
-    } else if (wasInFilamentChange && !isMqttFilamentChangeActive) {
-      // Transition from active stage to idle (any non-filament-change stage, not just -1)
+    } else if (wasActive && !isMqttFilamentChangeActive) {
+      // Transition from active (1) to idle (0)
       // Close the card by clearing user state
+      console.log(`[AMSSectionDual] ams_status_main transitioned 1->0, closing card`);
       setUserFilamentChange(null);
     }
 
-    // Update previous stage for next comparison
-    prevStageRef.current = currentStage;
-  }, [isMqttFilamentChangeActive, currentStage]);
+    // Update previous status for next comparison
+    prevAmsStatusMainRef.current = amsStatusMain;
+  }, [isMqttFilamentChangeActive, amsStatusMain]);
 
-  // Show FilamentChangeCard when either MQTT reports active stage OR user just clicked load/unload
+  // Show FilamentChangeCard when either MQTT reports active ams_status OR user just clicked load/unload
   const showFilamentChangeCard = isMqttFilamentChangeActive || userFilamentChange !== null;
 
   // Determine if loading or unloading for the card display
+  // Use user intent if available, otherwise default to loading (most common operation)
   const isFilamentLoading = userFilamentChange !== null
     ? userFilamentChange.isLoading
-    : (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_HEATING_NOZZLE);
+    : true; // Default to loading when detected via MQTT
 
   // Get the loaded tray info for wire coloring
   // Wire coloring should show the path from the currently loaded filament to the extruder
@@ -905,7 +1047,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
         const tray = unit.tray[slotIndex];
         const color = tray?.tray_color ?? null;
 
-        // Determine if this AMS is on left or right side
+        // Determine if this AMS is on left or right UI panel
+        // UI layout: extruder 0 = left panel, extruder 1 = right panel
         const extruderId = amsExtruderMap[String(unit.id)];
 
         // Check if this AMS unit is the one currently displayed in the panel
@@ -913,7 +1056,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
         const currentRightUnit = rightUnits[rightAmsIndex];
 
         if (extruderId === 0) {
-          // Left side (extruder 0) - leftUnits filters for amsExtruderMap === 0
+          // Left UI panel (extruder 0) - leftUnits filters for amsExtruderMap === 0
           // Only show colored wiring if the currently displayed AMS unit is the one with loaded filament
           const isDisplayed = currentLeftUnit?.id === unit.id;
           return {
@@ -923,7 +1066,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
             rightFilamentColor: null
           };
         } else {
-          // Right side (extruder 1) - rightUnits filters for amsExtruderMap === 1
+          // Right UI panel (extruder 1) - rightUnits filters for amsExtruderMap === 1
           const isDisplayed = currentRightUnit?.id === unit.id;
           return {
             leftActiveSlot: null,
@@ -940,6 +1083,9 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
 
   const { leftActiveSlot, rightActiveSlot, leftFilamentColor, rightFilamentColor } = getLoadedTrayInfo();
 
+  // Use state-based refreshing slot for spinner visibility (minimum 1.5s display time)
+  const refreshingSlot = refreshingSlotState;
+
   return (
     <div className="bg-bambu-dark-tertiary rounded-[10px] p-3">
       {/* Dual Panel Layout - just the panels, no wiring */}
@@ -955,6 +1101,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
           onHumidityClick={handleHumidityClick}
           onSlotRefresh={handleSlotRefresh}
           onEyeClick={handleEyeClick}
+          refreshingSlot={refreshingSlot}
         />
 
         {isDualNozzle && (
@@ -969,6 +1116,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
             onHumidityClick={handleHumidityClick}
             onSlotRefresh={handleSlotRefresh}
             onEyeClick={handleEyeClick}
+            refreshingSlot={refreshingSlot}
           />
         )}
       </div>
@@ -1044,7 +1192,11 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
       {showFilamentChangeCard && (
         <FilamentChangeCard
           isLoading={isFilamentLoading}
-          currentStage={currentStage}
+          amsStatusMain={amsStatusMain}
+          amsStatusSub={status?.ams_status_sub ?? 0}
+          trayNow={trayNow}
+          targetTrayId={userFilamentChange?.targetTrayId ?? null}
+          onComplete={handleFilamentChangeComplete}
         />
       )}
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BEhbt1yZ.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BREH29lW.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CLt_WkAI.js


+ 2 - 2
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов