Martin Ziegler 5 місяців тому
батько
коміт
eb6d6e6ce5

+ 37 - 31
backend/app/services/bambu_mqtt.py

@@ -753,10 +753,13 @@ class BambuMQTTClient:
             if ams_id is not None and info is not None:
                 try:
                     info_val = int(info) if isinstance(info, str) else info
-                    # Extract bit 8 for extruder assignment (0=right, 1=left)
-                    extruder_id = (info_val >> 8) & 0x1
+                    # Extract bit 8 for extruder assignment
+                    # Bit 8 = 0 means LEFT extruder (id 1), bit 8 = 1 means RIGHT extruder (id 0)
+                    # So we invert: extruder_id = 1 - bit8
+                    bit8 = (info_val >> 8) & 0x1
+                    extruder_id = 1 - bit8  # 0=right, 1=left
                     ams_extruder_map[str(ams_id)] = extruder_id
-                    logger.debug(f"[{self.serial_number}] AMS {ams_id} info={info_val} -> extruder {extruder_id}")
+                    logger.debug(f"[{self.serial_number}] AMS {ams_id} info={info_val} (bit8={bit8}) -> extruder {extruder_id}")
                 except (ValueError, TypeError):
                     pass
         if ams_extruder_map:
@@ -2394,8 +2397,7 @@ class BambuMQTTClient:
 
         Args:
             tray_id: Global tray ID (0-15 for AMS slots, or 254 for external spool)
-            extruder_id: Extruder ID for dual-nozzle printers (0=right, 1=left).
-                         If None, defaults to 0.
+            extruder_id: Unused - kept for API compatibility
 
         Returns:
             True if command was sent, False otherwise
@@ -2404,11 +2406,7 @@ class BambuMQTTClient:
             logger.warning(f"[{self.serial_number}] Cannot load filament: not connected")
             return False
 
-        # Default to extruder 0 if not specified
-        curr_nozzle = extruder_id if extruder_id is not None else 0
-
-        # Convert global tray_id to ams_id and slot_id
-        # External spool is special case (254)
+        # Calculate ams_id and slot_id for logging
         if tray_id == 254:
             ams_id = 255  # External spool
             slot_id = 254
@@ -2416,24 +2414,26 @@ class BambuMQTTClient:
             ams_id = tray_id // 4  # AMS unit (0, 1, 2, 3...)
             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 format from BambuStudio traffic capture:
+        # - No extruder_id field
+        # - curr_temp and tar_temp are -1 (not 0)
+        self._sequence_id += 1
         command = {
             "print": {
                 "command": "ams_change_filament",
-                "target": tray_id,  # Global tray ID (ams_id * 4 + slot_id)
+                "sequence_id": str(self._sequence_id),
                 "ams_id": ams_id,
                 "slot_id": slot_id,
-                "curr_temp": 220,
-                "tar_temp": 220,
-                "sequence_id": "0"
+                "target": tray_id,
+                "curr_temp": -1,
+                "tar_temp": -1
             }
         }
 
         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}")
+        logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id} (AMS {ams_id} slot {slot_id})")
 
         # 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
@@ -2457,32 +2457,38 @@ class BambuMQTTClient:
         tray_now = self.state.tray_now
         logger.info(f"[{self.serial_number}] Unload requested, tray_now={tray_now}")
 
-        # Determine the ams_id from tray_now (if valid tray is loaded)
-        # From BambuStudio source: unload requires target=255 AND slot_id=255
-        # plus the ams_id of the source AMS unit
-        if tray_now is not None and tray_now < 254:
-            ams_id = tray_now // 4
+        # Determine source ams_id for the unload command
+        if tray_now == 255 or tray_now == 254:
+            ams_id = 255  # No filament or external spool
         else:
-            ams_id = 0  # Default to AMS 0 if no tray loaded
+            ams_id = tray_now // 4  # Source AMS
 
-        # Build unload command using BambuStudio's "new protocol"
-        # Key: Both target=255 AND slot_id=255 are required for unload
+        # Command format from BambuStudio traffic capture:
+        # - No extruder_id field
+        # - For UNLOAD: curr_temp and tar_temp are the actual nozzle temp (e.g., 210)
+        # - slot_id=255 and target=255 for unload
+        # Get current nozzle temperature for the unload command
+        nozzle_temp = int(self.state.temperatures.get("nozzle", 210))
+        if nozzle_temp < 180:
+            nozzle_temp = 210  # Default to PLA temp if nozzle is cold
+
+        self._sequence_id += 1
         command = {
             "print": {
                 "command": "ams_change_filament",
-                "sequence_id": "0",
-                "target": 255,    # 255 = unload
-                "slot_id": 255,   # 255 = unload (new protocol)
+                "sequence_id": str(self._sequence_id),
                 "ams_id": ams_id,
-                "curr_temp": 220,
-                "tar_temp": 220
+                "slot_id": 255,  # 255 = unload marker
+                "target": 255,   # 255 = unload destination
+                "curr_temp": nozzle_temp,
+                "tar_temp": nozzle_temp
             }
         }
 
         command_json = json.dumps(command)
         logger.info(f"[{self.serial_number}] Publishing ams_change_filament (unload) command: {command_json}")
         self._client.publish(self.topic_publish, command_json)
-        logger.info(f"[{self.serial_number}] Unloading filament from AMS {ams_id}")
+        logger.info(f"[{self.serial_number}] Unloading filament (tray_now was {tray_now})")
 
         # Clear tracked load request since we're unloading
         self._last_load_tray_id = None

+ 4 - 0
backend/app/services/printer_manager.py

@@ -283,6 +283,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
             {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
             for e in (state.hms_errors or [])
         ],
+        # AMS status for filament change tracking
+        "ams_status_main": state.ams_status_main,
+        "ams_status_sub": state.ams_status_sub,
+        "tray_now": state.tray_now,
     }
     # Add cover URL if there's an active print and printer_id is provided
     if printer_id and state.state == "RUNNING" and state.gcode_file:

Різницю між файлами не показано, бо вона завелика
+ 1 - 2
frontend/public/icons/ams-settings.svg


BIN
frontend/public/icons/dual-extruder-left.png


BIN
frontend/public/icons/dual-extruder-right.png


BIN
frontend/public/icons/dual-extruder-right_sav.png


BIN
frontend/public/icons/extruder-left-right.png


+ 6 - 2
frontend/public/icons/hotend.svg

@@ -1,2 +1,6 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" width="512" height="512" viewBox="0 0 24 24"><path d="M20.5,24c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.113-.252,.409-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Zm-7,0c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.112-.252,.409-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Zm-7,0c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.112-.252,.41-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Z"/></svg>
+<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.4759 7.06822H13.7562L8.74263 11.2268C8.514 11.416 8.10431 11.416 7.87554 11.227C7.87545 11.2269 7.87536 11.2268 7.87528 11.2268L2.86167 7.06822H4.142C4.54877 7.06822 4.93613 6.94338 5.23267 6.71974C5.52893 6.49633 5.76004 6.14967 5.76004 5.72506V1.84316C5.76004 1.80403 5.78049 1.72911 5.88953 1.64688C5.99827 1.56488 6.16993 1.5 6.37809 1.5H10.2398C10.448 1.5 10.6196 1.56488 10.7284 1.64688C10.8374 1.72911 10.8579 1.80403 10.8579 1.84316V5.72506C10.8579 6.14967 11.089 6.49633 11.3852 6.71974C11.6818 6.94338 12.0691 7.06822 12.4759 7.06822ZM2.36979 7.09452C2.36773 7.09555 2.36658 7.096 2.36652 7.09597C2.36645 7.09594 2.36748 7.09542 2.36979 7.09452ZM14.2475 7.09456C14.2498 7.09545 14.2508 7.09596 14.2507 7.096C14.2507 7.09603 14.2495 7.09558 14.2475 7.09456Z" stroke="#6B6B6B"/>
+<path d="M3.80389 10.668C3.58699 10.7742 3.42822 10.9007 3.42822 11.0895C3.42822 11.673 4.95994 11.673 4.95994 12.2548C4.95994 12.8383 3.42822 12.8383 3.42822 13.42C3.42822 14.0035 4.95994 14.0035 4.95994 14.587C4.95994 15.1704 3.42822 15.1704 3.42822 15.7539" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M8.63467 14.1348C8.88288 14.2477 9.07518 14.381 9.07518 14.5867C9.07518 15.1702 7.54346 15.1702 7.54346 15.7536" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M11.893 11.4316C12.3223 11.7065 13.1899 11.8161 13.1899 12.2546C13.1899 12.838 11.6582 12.838 11.6582 13.4198C11.6582 14.0033 13.1899 14.0033 13.1899 14.5867C13.1899 15.1702 11.6582 15.1702 11.6582 15.7537" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+</svg>

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
frontend/public/icons/speed.svg


Різницю між файлами не показано, бо вона завелика
+ 0 - 2
frontend/public/icons/ventilation.svg


+ 111 - 33
frontend/src/components/control/AMSSectionDual.tsx

@@ -151,6 +151,7 @@ interface FilamentChangeCardProps {
   targetTrayId: number | null;  // Target tray we're trying to load (null for unload)
   onComplete: () => void;  // Called when operation completes
   onRetry?: () => void;
+  operationInitiated: boolean;  // True if we initiated the operation (hook is in LOADING/UNLOADING state)
 }
 
 interface StepInfo {
@@ -159,7 +160,7 @@ interface StepInfo {
   stepNumber: number;
 }
 
-function FilamentChangeCard({ isLoading, amsStatusMain, amsStatusSub, trayNow, targetTrayId, onComplete, onRetry }: FilamentChangeCardProps) {
+function FilamentChangeCard({ isLoading, amsStatusMain, amsStatusSub, trayNow, targetTrayId, onComplete, onRetry, operationInitiated }: FilamentChangeCardProps) {
   const [isCollapsed, setIsCollapsed] = useState(false);
   const [isCompleted, setIsCompleted] = useState(false);
   const prevAmsStatusRef = useRef(amsStatusMain);
@@ -167,31 +168,47 @@ function FilamentChangeCard({ isLoading, amsStatusMain, amsStatusSub, trayNow, t
   const completionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
 
   // ams_status_sub values for filament change steps
-  // Observed progression: 5 -> 6 -> 2 -> 7 -> 0
+  // Observed from H2D printer logs:
+  // Load progression: 9 (feeding start) -> 5 (prep) -> 6 (pushing) -> 2 (heating) -> 6 (more pushing) -> 7 (purging)
+  // Unload progression: 9 (start) -> 2 (heating) -> 3 (retract prep) -> 4 (retract)
   // 2: Heating nozzle
-  // 3: AMS feeding filament to hub
+  // 3: AMS feeding filament to hub / retract prep
   // 4: Retraction / extruder pulling filament
   // 5: Initial filament push / preparation
   // 6: Load verification / extruder pushing
   // 7: Purging
+  // 9: Feeding start / operation initiated
   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;
+  const SUB_FEEDING_START = 9;
 
   // Log status updates for debugging
   useEffect(() => {
     console.log(`[FilamentChangeCard] Status: main=${amsStatusMain}, sub=${amsStatusSub}, trayNow=${trayNow}, isLoading=${isLoading}`);
   }, [amsStatusMain, amsStatusSub, trayNow, isLoading]);
 
+  // Track component mount time for minimum operation duration check
+  const mountTimeRef = useRef(Date.now());
+
   // Detect completion via ams_status_main transition from 1 (filament_change) to 0 (idle)
   // Also use tray_now as a secondary indicator
   useEffect(() => {
     const wasActive = prevAmsStatusRef.current === 1;
     const isNowIdle = amsStatusMain === 0;
     const trayChanged = trayNow !== prevTrayNowRef.current;
+    const elapsed = Date.now() - mountTimeRef.current;
+
+    // Don't detect completion in the first 3 seconds - operation can't complete that fast
+    // This prevents false positives from initial state or rapid updates
+    if (elapsed < 3000) {
+      prevAmsStatusRef.current = amsStatusMain;
+      prevTrayNowRef.current = trayNow;
+      return;
+    }
 
     // Primary completion detection: ams_status_main transitions from 1 to 0
     if (wasActive && isNowIdle) {
@@ -239,23 +256,29 @@ function FilamentChangeCard({ isLoading, amsStatusMain, amsStatusSub, trayNow, t
   const getStepFromAmsStatusSub = (): number => {
     if (isCompleted) return 99; // All done
 
-    // If not in filament change mode, not started
-    if (amsStatusMain !== 1) return 0;
+    // If not in filament change mode yet, check if we initiated the operation
+    // This handles the delay between sending the command and MQTT status updating
+    if (amsStatusMain !== 1) {
+      // If we initiated the operation, show step 1 as in progress
+      if (operationInitiated) return 1;
+      return 0; // Not started
+    }
 
     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
+      // Observed progression: 9 (start) -> 5 (prep) -> 6 (push) -> 2 (heat) -> 6 (push) -> 7 (purge)
+      // Map sub status to steps: 9/5/6 -> step 1, 2 -> step 2, 7 -> step 3
+      if (amsStatusSub === SUB_FEEDING_START || 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
+      // Observed progression: 9 (start) -> 2 (heat) -> 3 (retract prep) -> 4 (retract)
+      // Map sub status to steps: 9/2 -> step 1, 3/4 -> step 2
+      if (amsStatusSub === SUB_FEEDING_START || amsStatusSub === SUB_HEATING) return 1; // Step 1: Heating
+      if (amsStatusSub === SUB_FEEDING || amsStatusSub === SUB_RETRACT) return 2; // Step 2: Retracting
       // Default to step 1 when in filament_change mode
       return 1;
     }
@@ -899,14 +922,13 @@ 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:
-  // 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)
+  // Bambu convention: extruder_id 0 = RIGHT physical nozzle, extruder_id 1 = LEFT physical nozzle
+  // UI layout: Left panel shows extruder 1 (left nozzle), Right panel shows extruder 0 (right nozzle)
   const leftUnits = (() => {
     if (!isDualNozzle) return amsUnits;
     if (Object.keys(amsExtruderMap).length > 0) {
-      // 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);
+      // Filter AMS units assigned to extruder 1 (LEFT physical nozzle -> left UI panel)
+      return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
     }
     // Fallback: even indices go to left
     return amsUnits.filter((_, i) => i % 2 === 0);
@@ -915,9 +937,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
   const rightUnits = (() => {
     if (!isDualNozzle) return [];
     if (Object.keys(amsExtruderMap).length > 0) {
-      // 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);
+      // Filter AMS units assigned to extruder 0 (RIGHT physical nozzle -> right UI panel)
+      return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
     }
     // Fallback: odd indices go to right
     return amsUnits.filter((_, i) => i % 2 === 1);
@@ -1033,12 +1054,66 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
   };
 
   // Show FilamentChangeCard when operation is in progress (LOADING or UNLOADING state)
+  // Use debounced visibility to prevent flickering when ams_status_main bounces
   const isMqttFilamentChangeActive = amsStatusMain === 1;
-  const showFilamentChangeCard = amsOps.state === 'LOADING' || amsOps.state === 'UNLOADING' || isMqttFilamentChangeActive;
+  const shouldShowCard = amsOps.state === 'LOADING' || amsOps.state === 'UNLOADING' || isMqttFilamentChangeActive;
+
+  // Debounce hiding the card - keep it visible for at least 2 seconds after conditions become false
+  const [showFilamentChangeCard, setShowFilamentChangeCard] = useState(false);
+  const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+  useEffect(() => {
+    if (shouldShowCard) {
+      // Clear any pending hide timeout
+      if (hideTimeoutRef.current) {
+        clearTimeout(hideTimeoutRef.current);
+        hideTimeoutRef.current = null;
+      }
+      setShowFilamentChangeCard(true);
+    } else if (showFilamentChangeCard) {
+      // Delay hiding the card by 1.5 seconds to prevent flicker
+      if (!hideTimeoutRef.current) {
+        hideTimeoutRef.current = setTimeout(() => {
+          setShowFilamentChangeCard(false);
+          hideTimeoutRef.current = null;
+        }, 1500);
+      }
+    }
+
+    return () => {
+      if (hideTimeoutRef.current) {
+        clearTimeout(hideTimeoutRef.current);
+      }
+    };
+  }, [shouldShowCard, showFilamentChangeCard]);
+
+  // Determine if it's a load operation for the FilamentChangeCard
+  // Simple logic: use state if active, otherwise use lastOperationType
+  // - LOADING state -> definitely load
+  // - UNLOADING state -> definitely unload
+  // - IDLE state -> use lastOperationType to remember what we initiated
+  // - Fallback: if lastOperationType is null and MQTT shows activity, infer from tray_now
+  //   (tray_now=255 means unloading, any other value means loading)
+  const isFilamentLoadOperation = (() => {
+    if (amsOps.state === 'LOADING') return true;
+    if (amsOps.state === 'UNLOADING') return false;
+    if (amsOps.lastOperationType === 'load') return true;
+    if (amsOps.lastOperationType === 'unload') return false;
+    // Fallback: infer from tray_now when MQTT shows filament change active
+    // If tray has a valid filament (not 255), we're loading
+    return trayNow !== 255;
+  })();
+
+  // Debug logging for card type determination
+  useEffect(() => {
+    if (showFilamentChangeCard) {
+      console.log(`[AMSSectionDual] Card type: isFilamentLoadOperation=${isFilamentLoadOperation}, state=${amsOps.state}, lastOperationType=${amsOps.lastOperationType}, trayNow=${trayNow}`);
+    }
+  }, [showFilamentChangeCard, isFilamentLoadOperation, amsOps.state, amsOps.lastOperationType, trayNow]);
 
-  // Get the loaded tray info for wire coloring
-  // Wire coloring should show the path from the currently loaded filament to the extruder
-  // But ONLY if the currently displayed AMS panel is the one with the loaded filament
+  // Get the loaded tray info for wire coloring and extruder inlet
+  // Wire coloring: only show path if the currently displayed AMS panel has the loaded filament
+  // Extruder inlet: ALWAYS show the loaded filament color regardless of which AMS is displayed
   const getLoadedTrayInfo = (): {
     leftActiveSlot: number | null;
     rightActiveSlot: number | null;
@@ -1060,31 +1135,33 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
         const color = tray?.tray_color ?? null;
 
         // Determine if this AMS is on left or right UI panel
-        // UI layout: extruder 0 = left panel, extruder 1 = right panel
+        // Bambu convention: extruder 0 = RIGHT physical nozzle, extruder 1 = LEFT physical nozzle
+        // UI layout: left panel shows extruder 1 (left nozzle), right panel shows extruder 0 (right nozzle)
         const extruderId = amsExtruderMap[String(unit.id)];
 
         // Check if this AMS unit is the one currently displayed in the panel
         const currentLeftUnit = leftUnits[leftAmsIndex];
         const currentRightUnit = rightUnits[rightAmsIndex];
 
-        if (extruderId === 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
+        if (extruderId === 1) {
+          // Left UI panel (extruder 1 = LEFT physical nozzle) - leftUnits filters for amsExtruderMap === 1
+          // Wire path: only show if the currently displayed AMS unit is the one with loaded filament
+          // Extruder inlet: ALWAYS show the loaded filament color
           const isDisplayed = currentLeftUnit?.id === unit.id;
           return {
-            leftActiveSlot: isDisplayed ? slotIndex : null,
+            leftActiveSlot: isDisplayed ? slotIndex : null,  // Wire path only when AMS is displayed
             rightActiveSlot: null,
-            leftFilamentColor: isDisplayed ? color : null,  // Hide color if different AMS is selected
+            leftFilamentColor: color,  // Extruder inlet always shows loaded color
             rightFilamentColor: null
           };
         } else {
-          // Right UI panel (extruder 1) - rightUnits filters for amsExtruderMap === 1
+          // Right UI panel (extruder 0 = RIGHT physical nozzle) - rightUnits filters for amsExtruderMap === 0
           const isDisplayed = currentRightUnit?.id === unit.id;
           return {
             leftActiveSlot: null,
-            rightActiveSlot: isDisplayed ? slotIndex : null,
+            rightActiveSlot: isDisplayed ? slotIndex : null,  // Wire path only when AMS is displayed
             leftFilamentColor: null,
-            rightFilamentColor: isDisplayed ? color : null  // Hide color if different AMS is selected
+            rightFilamentColor: color  // Extruder inlet always shows loaded color
           };
         }
       }
@@ -1211,12 +1288,13 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
       {/* Filament Change Progress Card - appears during load/unload operations */}
       {showFilamentChangeCard && (
         <FilamentChangeCard
-          isLoading={amsOps.isLoadOperation}
+          isLoading={isFilamentLoadOperation}
           amsStatusMain={amsStatusMain}
           amsStatusSub={status?.ams_status_sub ?? 0}
           trayNow={trayNow}
           targetTrayId={amsOps.loadTargetTrayId}
           onComplete={handleFilamentChangeComplete}
+          operationInitiated={amsOps.state === 'LOADING' || amsOps.state === 'UNLOADING'}
         />
       )}
 

+ 15 - 2
frontend/src/components/control/useAmsOperations.ts

@@ -67,6 +67,9 @@ interface UseAmsOperationsReturn {
   // For FilamentChangeCard - which type of operation
   isLoadOperation: boolean;
   loadTargetTrayId: number | null;
+  // Last initiated operation type - persists after state reset
+  // Used to determine card type when MQTT shows change but our state is IDLE
+  lastOperationType: 'load' | 'unload' | null;
 
   // Mutation error states (for UI feedback)
   loadError: Error | null;
@@ -88,6 +91,10 @@ export function useAmsOperations({
 }: UseAmsOperationsProps): UseAmsOperationsReturn {
   const [state, setState] = useState<OperationState>('IDLE');
   const [context, setContext] = useState<OperationContext | null>(null);
+  // Track the last operation type (load vs unload) - persists after state reset
+  // This helps show correct card type when MQTT shows filament change but our state is IDLE
+  // Using state instead of ref so changes trigger re-renders in consuming components
+  const [lastOperationType, setLastOperationType] = useState<'load' | 'unload' | null>(null);
 
   // Track previous values for transition detection
   const prevAmsStatusMainRef = useRef(amsStatusMain);
@@ -210,6 +217,7 @@ export function useAmsOperations({
     const startTime = Date.now();
     setState('LOADING');
     setContext({ loadTargetTrayId: trayId, startTime });
+    setLastOperationType('load'); // Remember this was a load operation
 
     // Set timeout
     timeoutRef.current = setTimeout(() => {
@@ -231,6 +239,7 @@ export function useAmsOperations({
     const startTime = Date.now();
     setState('UNLOADING');
     setContext({ startTime });
+    setLastOperationType('unload'); // Remember this was an unload operation
 
     // Set timeout
     timeoutRef.current = setTimeout(() => {
@@ -306,11 +315,14 @@ export function useAmsOperations({
   }, [state, amsStatusMain, reset]);
 
   // Secondary completion detection for LOAD: tray_now matches target
+  // Wait at least 5 seconds to ensure the filament actually reached the nozzle
+  // (tray_now can be updated before the physical load is complete)
   useEffect(() => {
     if (state !== 'LOADING' || !context?.loadTargetTrayId) return;
 
-    if (trayNow === context.loadTargetTrayId) {
-      console.log(`[useAmsOperations] Load complete: tray_now=${trayNow} matches target`);
+    const elapsed = context?.startTime ? Date.now() - context.startTime : 0;
+    if (trayNow === context.loadTargetTrayId && elapsed > 5000) {
+      console.log(`[useAmsOperations] Load complete: tray_now=${trayNow} matches target (${elapsed}ms elapsed)`);
       reset();
     }
   }, [state, context, trayNow, reset]);
@@ -357,6 +369,7 @@ export function useAmsOperations({
     isRefreshingSlot,
     isLoadOperation,
     loadTargetTrayId,
+    lastOperationType,
     loadError: loadMutation.error,
     unloadError: unloadMutation.error,
     refreshError: refreshMutation.error,

+ 5 - 0
frontend/vite.config.ts

@@ -10,6 +10,11 @@ export default defineConfig({
   },
   server: {
     proxy: {
+      '/api/v1/ws': {
+        target: 'http://localhost:8000',
+        ws: true,
+        changeOrigin: true,
+      },
       '/api': {
         target: 'http://localhost:8000',
         changeOrigin: true,

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-DbzgStXX.js


Різницю між файлами не показано, бо вона завелика
+ 1 - 2
static/icons/ams-settings.svg


BIN
static/icons/dual-extruder-left.png


BIN
static/icons/dual-extruder-right.png


BIN
static/icons/dual-extruder-right_sav.png


BIN
static/icons/extruder-left-right.png


+ 6 - 2
static/icons/hotend.svg

@@ -1,2 +1,6 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" width="512" height="512" viewBox="0 0 24 24"><path d="M20.5,24c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.113-.252,.409-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Zm-7,0c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.112-.252,.409-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Zm-7,0c-.042,0-.086-.005-.129-.017-.267-.071-.426-.345-.354-.612,.114-.428,.243-.826,.369-1.218,.315-.98,.614-1.906,.614-3.16,0-2.766-1.029-4.801-1.927-6.265-.965-1.576-2.073-3.771-2.073-6.788,0-1.884,.772-4.482,1.294-5.646,.112-.252,.41-.365,.661-.251,.252,.113,.364,.409,.251,.661-.421,.938-1.206,3.436-1.206,5.236,0,2.766,1.029,4.801,1.927,6.265,.965,1.576,2.073,3.771,2.073,6.788,0,1.411-.337,2.456-.663,3.467-.121,.375-.244,.758-.354,1.168-.06,.224-.262,.371-.483,.371Z"/></svg>
+<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.4759 7.06822H13.7562L8.74263 11.2268C8.514 11.416 8.10431 11.416 7.87554 11.227C7.87545 11.2269 7.87536 11.2268 7.87528 11.2268L2.86167 7.06822H4.142C4.54877 7.06822 4.93613 6.94338 5.23267 6.71974C5.52893 6.49633 5.76004 6.14967 5.76004 5.72506V1.84316C5.76004 1.80403 5.78049 1.72911 5.88953 1.64688C5.99827 1.56488 6.16993 1.5 6.37809 1.5H10.2398C10.448 1.5 10.6196 1.56488 10.7284 1.64688C10.8374 1.72911 10.8579 1.80403 10.8579 1.84316V5.72506C10.8579 6.14967 11.089 6.49633 11.3852 6.71974C11.6818 6.94338 12.0691 7.06822 12.4759 7.06822ZM2.36979 7.09452C2.36773 7.09555 2.36658 7.096 2.36652 7.09597C2.36645 7.09594 2.36748 7.09542 2.36979 7.09452ZM14.2475 7.09456C14.2498 7.09545 14.2508 7.09596 14.2507 7.096C14.2507 7.09603 14.2495 7.09558 14.2475 7.09456Z" stroke="#6B6B6B"/>
+<path d="M3.80389 10.668C3.58699 10.7742 3.42822 10.9007 3.42822 11.0895C3.42822 11.673 4.95994 11.673 4.95994 12.2548C4.95994 12.8383 3.42822 12.8383 3.42822 13.42C3.42822 14.0035 4.95994 14.0035 4.95994 14.587C4.95994 15.1704 3.42822 15.1704 3.42822 15.7539" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M8.63467 14.1348C8.88288 14.2477 9.07518 14.381 9.07518 14.5867C9.07518 15.1702 7.54346 15.1702 7.54346 15.7536" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M11.893 11.4316C12.3223 11.7065 13.1899 11.8161 13.1899 12.2546C13.1899 12.838 11.6582 12.838 11.6582 13.4198C11.6582 14.0033 13.1899 14.0033 13.1899 14.5867C13.1899 15.1702 11.6582 15.1702 11.6582 15.7537" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+</svg>

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/icons/speed.svg


Різницю між файлами не показано, бо вона завелика
+ 0 - 2
static/icons/ventilation.svg


+ 1 - 1
static/index.html

@@ -7,7 +7,7 @@
     <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-BHvBWY5n.js"></script>
+    <script type="module" crossorigin src="/assets/index-DbzgStXX.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BpSfhfce.css">
   </head>
   <body>

Деякі файли не було показано, через те що забагато файлів було змінено