Browse Source

Completely refactored k-profile module

Martin Ziegler 5 months ago
parent
commit
6de875582a

+ 73 - 12
backend/app/api/routes/kprofiles.py

@@ -1,5 +1,6 @@
 """API routes for K-profile (pressure advance) management."""
 
+import asyncio
 import logging
 
 from fastapi import APIRouter, Depends, HTTPException
@@ -77,10 +78,22 @@ async def set_kprofile(
 ):
     """Create or update a K-profile on the printer.
 
+    For H2D edits (slot_id > 0), this performs an in-place edit using cali_idx.
+    For other printers or new profiles, this adds a new profile.
+
     Args:
         printer_id: ID of the printer
         profile: K-profile data to set
     """
+    is_edit = profile.slot_id > 0
+    operation = "edit" if is_edit else "add"
+
+    logger.info(
+        f"[API] set_kprofile ({operation}): printer={printer_id}, slot_id={profile.slot_id}, "
+        f"extruder_id={profile.extruder_id}, nozzle_id={profile.nozzle_id}, "
+        f"name={profile.name}, filament_id={profile.filament_id}, k_value={profile.k_value}"
+    )
+
     # Check printer exists
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -92,22 +105,69 @@ async def set_kprofile(
     if not client or not client.state.connected:
         raise HTTPException(400, "Printer not connected")
 
-    # Send the K-profile to printer
-    success = client.set_kprofile(
-        filament_id=profile.filament_id,
-        name=profile.name,
-        k_value=profile.k_value,
-        nozzle_diameter=profile.nozzle_diameter,
-        nozzle_id=profile.nozzle_id,
-        extruder_id=profile.extruder_id,
-        setting_id=profile.setting_id,
-        slot_id=profile.slot_id,
-    )
+    # Detect H2D by serial number prefix
+    is_h2d = printer.serial_number.startswith("094")
+
+    if is_edit and is_h2d:
+        # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id
+        logger.info(f"[API] H2D in-place edit: cali_idx={profile.slot_id}")
+        success = client.set_kprofile(
+            filament_id=profile.filament_id,
+            name=profile.name,
+            k_value=profile.k_value,
+            nozzle_diameter=profile.nozzle_diameter,
+            nozzle_id=profile.nozzle_id,
+            extruder_id=profile.extruder_id,
+            setting_id=None,
+            slot_id=0,
+            cali_idx=profile.slot_id,  # Pass the original slot for in-place edit
+        )
+    elif is_edit:
+        # Non-H2D edit: use delete + add approach
+        logger.info(f"[API] Edit: deleting existing profile slot_id={profile.slot_id}")
+        delete_success = client.delete_kprofile(
+            cali_idx=profile.slot_id,
+            filament_id=profile.filament_id,
+            nozzle_id=profile.nozzle_id,
+            nozzle_diameter=profile.nozzle_diameter,
+            extruder_id=profile.extruder_id,
+            setting_id=profile.setting_id,
+        )
+        if not delete_success:
+            raise HTTPException(500, "Failed to delete existing K-profile for edit")
+
+        # Wait for printer to process the delete before adding
+        await asyncio.sleep(0.5)
+        logger.info("[API] Edit: delete complete, now adding updated profile")
+
+        success = client.set_kprofile(
+            filament_id=profile.filament_id,
+            name=profile.name,
+            k_value=profile.k_value,
+            nozzle_diameter=profile.nozzle_diameter,
+            nozzle_id=profile.nozzle_id,
+            extruder_id=profile.extruder_id,
+            setting_id=None,  # Generate new setting_id for add
+            slot_id=0,  # Always 0 for add (new profile)
+        )
+    else:
+        # New profile: add with slot_id=0
+        success = client.set_kprofile(
+            filament_id=profile.filament_id,
+            name=profile.name,
+            k_value=profile.k_value,
+            nozzle_diameter=profile.nozzle_diameter,
+            nozzle_id=profile.nozzle_id,
+            extruder_id=profile.extruder_id,
+            setting_id=None,  # Generate new setting_id for add
+            slot_id=0,  # Always 0 for add (new profile)
+        )
 
     if not success:
         raise HTTPException(500, "Failed to send K-profile command")
 
-    return {"success": True, "message": "K-profile set successfully"}
+    message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
+    return {"success": True, "message": message}
 
 
 @router.delete("/", response_model=dict)
@@ -140,6 +200,7 @@ async def delete_kprofile(
         nozzle_id=profile.nozzle_id,
         nozzle_diameter=profile.nozzle_diameter,
         extruder_id=profile.extruder_id,
+        setting_id=profile.setting_id,
     )
 
     if not success:

+ 1 - 0
backend/app/schemas/kprofile.py

@@ -50,3 +50,4 @@ class KProfileDelete(BaseModel):
     nozzle_id: str  # e.g., "HH00-0.4"
     nozzle_diameter: str  # e.g., "0.4"
     filament_id: str  # Bambu filament identifier
+    setting_id: str | None = None  # Setting ID (for X1C series)

+ 182 - 73
backend/app/services/bambu_mqtt.py

@@ -119,6 +119,8 @@ class BambuMQTTClient:
             client.subscribe(self.topic_subscribe)
             # Request full status update
             self._request_push_all()
+            # Prime K-profile request (Bambu printers often ignore first request)
+            self._prime_kprofile_request()
             # Immediately broadcast connection state change
             if self.on_state_change:
                 self.on_state_change(self.state)
@@ -412,6 +414,25 @@ class BambuMQTTClient:
             message = {"pushing": {"command": "pushall"}}
             self._client.publish(self.topic_publish, json.dumps(message))
 
+    def _prime_kprofile_request(self):
+        """Send a priming K-profile request on connect.
+
+        Bambu printers often ignore the first K-profile request after connection,
+        so we send a dummy request on connect to 'prime' the system.
+        """
+        if self._client:
+            self._sequence_id += 1
+            command = {
+                "print": {
+                    "command": "extrusion_cali_get",
+                    "filament_id": "",
+                    "nozzle_diameter": "0.4",
+                    "sequence_id": str(self._sequence_id),
+                }
+            }
+            logger.debug(f"[{self.serial_number}] Sending K-profile priming request")
+            self._client.publish(self.topic_publish, json.dumps(command))
+
     def connect(self, loop: asyncio.AbstractEventLoop | None = None):
         """Connect to the printer MQTT broker.
 
@@ -568,12 +589,16 @@ class BambuMQTTClient:
 
         logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
 
-    async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 10.0) -> list[KProfile]:
-        """Request K-profiles from the printer.
+    async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3) -> list[KProfile]:
+        """Request K-profiles from the printer with retry logic.
+
+        Bambu printers sometimes ignore the first K-profile request, so we
+        implement retry logic to ensure reliable retrieval.
 
         Args:
             nozzle_diameter: Filter by nozzle diameter (e.g., "0.4")
-            timeout: Timeout in seconds to wait for response
+            timeout: Timeout in seconds to wait for each response attempt
+            max_retries: Maximum number of retry attempts
 
         Returns:
             List of KProfile objects
@@ -589,33 +614,41 @@ class BambuMQTTClient:
             logger.warning(f"[{self.serial_number}] No running event loop")
             return []
 
-        # Set up response event
-        self._sequence_id += 1
-        self._pending_kprofile_response = asyncio.Event()
-        self._kprofile_response_data = None
-
-        # Send the command
-        command = {
-            "print": {
-                "command": "extrusion_cali_get",
-                "filament_id": "",
-                "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+        for attempt in range(max_retries):
+            # Set up response event for this attempt
+            self._sequence_id += 1
+            self._pending_kprofile_response = asyncio.Event()
+            self._kprofile_response_data = None
+
+            # Send the command
+            command = {
+                "print": {
+                    "command": "extrusion_cali_get",
+                    "filament_id": "",
+                    "nozzle_diameter": nozzle_diameter,
+                    "sequence_id": str(self._sequence_id),
+                }
             }
-        }
 
-        logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter}")
-        self._client.publish(self.topic_publish, json.dumps(command))
+            logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter} (attempt {attempt + 1}/{max_retries})")
+            self._client.publish(self.topic_publish, json.dumps(command))
 
-        # Wait for response
-        try:
-            await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
-            return self._kprofile_response_data or []
-        except asyncio.TimeoutError:
-            logger.warning(f"[{self.serial_number}] Timeout waiting for K-profiles response")
-            return []
-        finally:
-            self._pending_kprofile_response = None
+            # Wait for response
+            try:
+                await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
+                profiles = self._kprofile_response_data or []
+                logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles on attempt {attempt + 1}")
+                return profiles
+            except asyncio.TimeoutError:
+                logger.warning(f"[{self.serial_number}] Timeout on K-profiles request attempt {attempt + 1}/{max_retries}")
+                if attempt < max_retries - 1:
+                    # Brief delay before retry
+                    await asyncio.sleep(0.5)
+            finally:
+                self._pending_kprofile_response = None
+
+        logger.error(f"[{self.serial_number}] Failed to get K-profiles after {max_retries} attempts")
+        return []
 
     def set_kprofile(
         self,
@@ -627,6 +660,7 @@ class BambuMQTTClient:
         extruder_id: int = 0,
         setting_id: str | None = None,
         slot_id: int = 0,
+        cali_idx: int | None = None,
     ) -> bool:
         """Set/update a K-profile on the printer.
 
@@ -639,6 +673,7 @@ class BambuMQTTClient:
             extruder_id: Extruder ID (0 or 1 for dual nozzle)
             setting_id: Existing setting ID for updates, None for new
             slot_id: Calibration index (cali_idx) for the profile
+            cali_idx: For H2D edits, the existing slot being edited (enables in-place edit)
 
         Returns:
             True if command was sent, False otherwise
@@ -649,43 +684,96 @@ class BambuMQTTClient:
 
         self._sequence_id += 1
 
-        # Build the filament entry - printer uses cali_idx for profile identification
-        # For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
-        cali_idx = -1 if slot_id == 0 else slot_id
-
-        # Generate a setting_id for new profiles (required by printer)
-        # Format: "PF" + 17 random digits
-        import random
-        if not setting_id and slot_id == 0:
-            setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}"
-
-        filament_entry = {
-            "ams_id": 0,
-            "cali_idx": cali_idx,
-            "extruder_id": extruder_id,
-            "filament_id": filament_id,
-            "k_value": k_value,
-            "n_coef": "0.000000",
-            "name": name,
-            "nozzle_diameter": nozzle_diameter,
-            "nozzle_id": nozzle_id,
-            "setting_id": setting_id,  # Always include setting_id
-            "tray_id": -1,
-        }
-
-        command = {
-            "print": {
-                "command": "extrusion_cali_set",
-                "filaments": [filament_entry],
+        # Detect printer type by serial number prefix
+        # X1C/P1/A1 series (single nozzle): serial starts with "00M", "00W", "01P", "01S", "03W", etc.
+        # H2D series (dual nozzle): serial starts with "094"
+        is_dual_nozzle = self.serial_number.startswith("094")
+
+        # For H2D edits, use empty setting_id per OrcaSlicer sniff
+        # For new profiles, generate a setting_id
+        import secrets
+        if cali_idx is not None:
+            # Edit mode - use empty setting_id per OrcaSlicer sniff
+            setting_id = ""
+        elif not setting_id and slot_id == 0:
+            # New profile - generate setting_id
+            setting_id = f"PFUS{secrets.token_hex(7)}"  # 7 bytes = 14 hex chars
+
+        if is_dual_nozzle:
+            # H2D format - exact OrcaSlicer format (captured via MQTT sniffing)
+            # For edits: include cali_idx (existing slot), slot_id=0, setting_id=""
+            # For new profiles: no cali_idx, slot_id=0, setting_id=generated
+            filament_entry = {
+                "ams_id": 0,
+                "extruder_id": extruder_id,
+                "filament_id": filament_id,
+                "k_value": k_value,
+                "n_coef": "0.000000",
+                "name": name,
+                "nozzle_diameter": nozzle_diameter,
+                "nozzle_id": nozzle_id,
+                "setting_id": setting_id if setting_id else "",
+                "slot_id": slot_id,
+                "tray_id": -1,
+            }
+            # For edits, add cali_idx field (position matters - alphabetical order)
+            if cali_idx is not None:
+                # Insert cali_idx in alphabetical position (after ams_id, before extruder_id)
+                # n_coef must be "0.000000" for H2D edits (matches OrcaSlicer sniff)
+                filament_entry = {
+                    "ams_id": 0,
+                    "cali_idx": cali_idx,
+                    "extruder_id": extruder_id,
+                    "filament_id": filament_id,
+                    "k_value": k_value,
+                    "n_coef": "0.000000",
+                    "name": name,
+                    "nozzle_diameter": nozzle_diameter,
+                    "nozzle_id": nozzle_id,
+                    "setting_id": "",
+                    "slot_id": 0,
+                    "tray_id": -1,
+                }
+            command = {
+                "print": {
+                    "command": "extrusion_cali_set",
+                    "filaments": [filament_entry],
+                    "nozzle_diameter": nozzle_diameter,
+                    "sequence_id": str(self._sequence_id),
+                }
+            }
+        else:
+            # X1C/P1/A1 format - based on actual X1C profile data:
+            # - n_coef: "1.000000" (NOT 0.000000 like H2D)
+            # - nozzle_id: "" (empty string, NOT the nozzle type)
+            # - tray_id: -1 (NOT 0)
+            filament_entry = {
+                "ams_id": 0,
+                "extruder_id": 0,  # X1C is single nozzle
+                "filament_id": filament_id,
+                "k_value": k_value,
+                "n_coef": "1.000000",  # X1C uses 1.0, not 0.0
+                "name": name,
                 "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+                "nozzle_id": "",  # X1C uses empty string
+                "setting_id": setting_id,
+                "slot_id": slot_id,
+                "tray_id": -1,  # X1C uses -1
+            }
+            command = {
+                "print": {
+                    "command": "extrusion_cali_set",
+                    "filaments": [filament_entry],
+                    "nozzle_diameter": nozzle_diameter,
+                    "sequence_id": str(self._sequence_id),
+                }
             }
-        }
 
         command_json = json.dumps(command)
-        logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0})")
-        logger.debug(f"[{self.serial_number}] K-profile command: {command_json}")
-        self._client.publish(self.topic_publish, command_json)
+        logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0}, dual={is_dual_nozzle})")
+        logger.info(f"[{self.serial_number}] K-profile SET command: {command_json}")
+        # Use QoS 1 for reliable delivery (at least once)
+        self._client.publish(self.topic_publish, command_json, qos=1)
         return True
 
     def delete_kprofile(
@@ -695,6 +783,7 @@ class BambuMQTTClient:
         nozzle_id: str,
         nozzle_diameter: str = "0.4",
         extruder_id: int = 0,
+        setting_id: str | None = None,
     ) -> bool:
         """Delete a K-profile from the printer.
 
@@ -704,6 +793,7 @@ class BambuMQTTClient:
             nozzle_id: Nozzle identifier (e.g., "HH00-0.4")
             nozzle_diameter: Nozzle diameter (e.g., "0.4")
             extruder_id: Extruder ID (0 or 1 for dual nozzle)
+            setting_id: Unique setting identifier (for X1C series)
 
         Returns:
             True if command was sent, False otherwise
@@ -714,20 +804,39 @@ class BambuMQTTClient:
 
         self._sequence_id += 1
 
-        command = {
-            "print": {
-                "command": "extrusion_cali_del",
-                "sequence_id": str(self._sequence_id),
-                "extruder_id": extruder_id,
-                "nozzle_id": nozzle_id,
-                "filament_id": filament_id,
-                "cali_idx": cali_idx,
-                "nozzle_diameter": nozzle_diameter,
+        # Detect printer type by serial number prefix
+        # H2D series (dual nozzle): serial starts with "094"
+        is_dual_nozzle = self.serial_number.startswith("094")
+
+        if is_dual_nozzle:
+            # H2D format: uses extruder_id, nozzle_id, nozzle_diameter
+            command = {
+                "print": {
+                    "command": "extrusion_cali_del",
+                    "sequence_id": str(self._sequence_id),
+                    "extruder_id": extruder_id,
+                    "nozzle_id": nozzle_id,
+                    "filament_id": filament_id,
+                    "cali_idx": cali_idx,
+                    "nozzle_diameter": nozzle_diameter,
+                }
+            }
+        else:
+            # X1C/P1/A1 format: uses setting_id, nozzle_diameter, no extruder/nozzle_id fields
+            command = {
+                "print": {
+                    "command": "extrusion_cali_del",
+                    "sequence_id": str(self._sequence_id),
+                    "filament_id": filament_id,
+                    "cali_idx": cali_idx,
+                    "setting_id": setting_id,
+                    "nozzle_diameter": nozzle_diameter,
+                }
             }
-        }
 
         command_json = json.dumps(command)
-        logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}")
-        logger.debug(f"[{self.serial_number}] K-profile delete command: {command_json}")
-        self._client.publish(self.topic_publish, command_json)
+        logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, dual={is_dual_nozzle}")
+        logger.info(f"[{self.serial_number}] K-profile DELETE command: {command_json}")
+        # Use QoS 1 for reliable delivery (at least once)
+        self._client.publish(self.topic_publish, command_json, qos=1)
         return True

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

@@ -351,6 +351,7 @@ export interface KProfileDelete {
   nozzle_id: string;  // e.g., "HH00-0.4"
   nozzle_diameter: string;  // e.g., "0.4"
   filament_id: string;  // Bambu filament identifier
+  setting_id?: string | null;  // Setting ID (for X1C series)
 }
 
 export interface KProfilesResponse {

+ 161 - 38
frontend/src/components/KProfilesView.tsx

@@ -128,7 +128,13 @@ function KProfileModal({
   const [modalDiameter, setModalDiameter] = useState(
     profile?.nozzle_diameter || nozzleDiameter
   );
-  const [extruderId, setExtruderId] = useState(profile?.extruder_id || 0);
+  // For new profiles on dual-nozzle: allow selecting multiple extruders
+  // For editing: use single extruder from the profile
+  const [selectedExtruders, setSelectedExtruders] = useState<number[]>(
+    profile ? [profile.extruder_id] : isDualNozzle ? [0, 1] : [0]  // Default: both extruders for new dual-nozzle profiles
+  );
+  const [isSyncing, setIsSyncing] = useState(false);
+  const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
 
   // Extract unique filaments from existing K-profiles on the printer
   // These have valid filament_ids that the printer recognizes
@@ -156,12 +162,20 @@ function KProfileModal({
     onSuccess: (result) => {
       console.log('[KProfile] Save success:', result);
       showToast('K-profile saved');
-      queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
-      onSave();
+      // Show syncing indicator while printer processes the command
+      setIsSyncing(true);
+      // Add delay before refreshing to give printer time to process the save
+      // Bambu printers can be slow to apply K-profile changes
+      setTimeout(() => {
+        queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
+        setIsSyncing(false);
+        onSave();
+      }, 2500);
     },
     onError: (error: Error) => {
       console.error('[KProfile] Save error:', error);
       showToast(error.message, 'error');
+      setIsSyncing(false);
     },
   });
 
@@ -173,12 +187,20 @@ function KProfileModal({
     onSuccess: (result) => {
       console.log('[KProfile] Delete success:', result);
       showToast('K-profile deleted');
-      queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
-      onClose();
+      // Show syncing indicator while printer processes the command
+      setIsSyncing(true);
+      // Add delay before refreshing to give printer time to process the delete
+      // Bambu printers can be slow to apply K-profile changes
+      setTimeout(() => {
+        queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
+        setIsSyncing(false);
+        onClose();
+      }, 2500);
     },
     onError: (error: Error) => {
       console.error('[KProfile] Delete error:', error);
       showToast(error.message, 'error');
+      setIsSyncing(false);
     },
   });
 
@@ -192,35 +214,101 @@ function KProfileModal({
       nozzle_id: profile.nozzle_id,
       nozzle_diameter: profile.nozzle_diameter,
       filament_id: profile.filament_id,
+      setting_id: profile.setting_id,
     });
   };
 
-  const handleSubmit = (e: React.FormEvent) => {
+  const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
+
+    // Validate at least one extruder is selected for dual-nozzle
+    if (isDualNozzle && !profile && selectedExtruders.length === 0) {
+      showToast('Please select at least one extruder', 'error');
+      return;
+    }
+
     // Format k_value to 6 decimal places for Bambu protocol
     const formattedKValue = parseFloat(kValue).toFixed(6);
     // Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
     const nozzleId = `${nozzleType}-${modalDiameter}`;
 
-    // Use the name from the form - it's auto-populated when filament is selected
-    // but can be edited by the user
-    const payload = {
-      name: name,
-      k_value: formattedKValue,
-      filament_id: filamentId,
-      nozzle_id: nozzleId,
-      nozzle_diameter: modalDiameter,
-      extruder_id: extruderId,
-      setting_id: profile?.setting_id,
-      slot_id: profile?.slot_id ?? 0,
-    };
-    console.log('[KProfile] Saving profile:', payload);
-    saveMutation.mutate(payload);
+    // For editing or single extruder: just save one profile
+    if (profile || selectedExtruders.length === 1) {
+      const payload = {
+        name: name,
+        k_value: formattedKValue,
+        filament_id: filamentId,
+        nozzle_id: nozzleId,
+        nozzle_diameter: modalDiameter,
+        extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
+        setting_id: profile?.setting_id,
+        slot_id: profile?.slot_id ?? 0,
+      };
+      console.log('[KProfile] Saving profile:', payload);
+      saveMutation.mutate(payload);
+      return;
+    }
+
+    // For new profiles with multiple extruders: save sequentially
+    setIsSyncing(true);
+    setSavingProgress({ current: 0, total: selectedExtruders.length });
+
+    for (let i = 0; i < selectedExtruders.length; i++) {
+      const extruderId = selectedExtruders[i];
+      const payload = {
+        name: name,
+        k_value: formattedKValue,
+        filament_id: filamentId,
+        nozzle_id: nozzleId,
+        nozzle_diameter: modalDiameter,
+        extruder_id: extruderId,
+        setting_id: undefined,
+        slot_id: 0,
+      };
+
+      setSavingProgress({ current: i + 1, total: selectedExtruders.length });
+      console.log(`[KProfile] Saving profile ${i + 1}/${selectedExtruders.length} for extruder ${extruderId}:`, payload);
+
+      try {
+        await api.setKProfile(printerId, payload);
+        // Wait between saves to let printer process
+        if (i < selectedExtruders.length - 1) {
+          await new Promise(resolve => setTimeout(resolve, 1500));
+        }
+      } catch (error) {
+        console.error(`[KProfile] Failed to save for extruder ${extruderId}:`, error);
+        showToast(`Failed to save for ${extruderId === 1 ? 'Left' : 'Right'} extruder`, 'error');
+        setIsSyncing(false);
+        setSavingProgress({ current: 0, total: 0 });
+        return;
+      }
+    }
+
+    showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
+    // Wait for final sync before closing
+    setTimeout(() => {
+      queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
+      setIsSyncing(false);
+      setSavingProgress({ current: 0, total: 0 });
+      onSave();
+    }, 2500);
   };
 
   return (
     <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
-      <Card className="w-full max-w-md">
+      <Card className="w-full max-w-md relative">
+        {/* Syncing overlay */}
+        {isSyncing && (
+          <div className="absolute inset-0 bg-bambu-dark-secondary/90 flex flex-col items-center justify-center z-10 rounded-lg">
+            <Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
+            <p className="text-white font-medium">
+              {savingProgress.total > 1
+                ? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...`
+                : 'Syncing with printer...'}
+            </p>
+            <p className="text-bambu-gray text-sm mt-1">Please wait</p>
+          </div>
+        )}
         <CardContent className="p-0">
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
             <h2 className="text-xl font-semibold text-white">
@@ -229,6 +317,7 @@ function KProfileModal({
             <button
               onClick={onClose}
               className="text-bambu-gray hover:text-white transition-colors"
+              disabled={isSyncing}
             >
               <X className="w-5 h-5" />
             </button>
@@ -245,7 +334,7 @@ function KProfileModal({
                 disabled={!!profile}
                 className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
                 placeholder="My PLA Profile"
-                required
+                required={!profile}
               />
             </div>
 
@@ -299,13 +388,13 @@ function KProfileModal({
                 }}
                 disabled={!!profile}
                 className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
-                required
+                required={!profile}
               >
                 <option value="">Select filament...</option>
-                {/* Show current filament when editing */}
+                {/* Show current filament when editing - look up from knownFilaments */}
                 {profile?.filament_id && (
                   <option key={profile.filament_id} value={profile.filament_id}>
-                    {extractFilamentName(profile.name || profile.filament_id)}
+                    {knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
                   </option>
                 )}
                 {/* Show known filaments from existing K-profiles (for new profiles) */}
@@ -364,19 +453,52 @@ function KProfileModal({
               </div>
             </div>
 
-            {/* Extruder - only show for dual-nozzle printers, read-only when editing */}
+            {/* Extruder - only show for dual-nozzle printers */}
             {isDualNozzle && (
               <div>
-                <label className="block text-sm text-bambu-gray mb-1">Extruder</label>
-                <select
-                  value={extruderId}
-                  onChange={(e) => setExtruderId(parseInt(e.target.value))}
-                  disabled={!!profile}
-                  className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
-                >
-                  <option value={1}>Left</option>
-                  <option value={0}>Right</option>
-                </select>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {profile ? 'Extruder' : 'Extruders'}
+                </label>
+                {profile ? (
+                  // Read-only display for editing
+                  <div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
+                    {profile.extruder_id === 1 ? 'Left' : 'Right'}
+                  </div>
+                ) : (
+                  // Checkboxes for new profile - can select both
+                  <div className="flex gap-4">
+                    <label className="flex items-center gap-2 cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={selectedExtruders.includes(1)}
+                        onChange={(e) => {
+                          if (e.target.checked) {
+                            setSelectedExtruders([...selectedExtruders, 1]);
+                          } else {
+                            setSelectedExtruders(selectedExtruders.filter(id => id !== 1));
+                          }
+                        }}
+                        className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
+                      />
+                      <span className="text-white">Left</span>
+                    </label>
+                    <label className="flex items-center gap-2 cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={selectedExtruders.includes(0)}
+                        onChange={(e) => {
+                          if (e.target.checked) {
+                            setSelectedExtruders([...selectedExtruders, 0]);
+                          } else {
+                            setSelectedExtruders(selectedExtruders.filter(id => id !== 0));
+                          }
+                        }}
+                        className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
+                      />
+                      <span className="text-white">Right</span>
+                    </label>
+                  </div>
+                )}
               </div>
             )}
 
@@ -386,7 +508,7 @@ function KProfileModal({
                   type="button"
                   variant="secondary"
                   onClick={() => setShowDeleteConfirm(true)}
-                  disabled={deleteMutation.isPending}
+                  disabled={deleteMutation.isPending || isSyncing}
                   className="text-red-500 hover:bg-red-500/10"
                 >
                   {deleteMutation.isPending ? (
@@ -400,13 +522,14 @@ function KProfileModal({
                 type="button"
                 variant="secondary"
                 onClick={onClose}
+                disabled={isSyncing}
                 className="flex-1"
               >
                 Cancel
               </Button>
               <Button
                 type="submit"
-                disabled={saveMutation.isPending}
+                disabled={saveMutation.isPending || isSyncing}
                 className="flex-1"
               >
                 {saveMutation.isPending ? (

+ 122 - 0
scripts/mqtt_sniffer.py

@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""MQTT Sniffer for Bambu Lab printers.
+
+Connects to a printer and logs all MQTT messages to capture the exact
+command format used by OrcaSlicer or Bambu Studio.
+
+Usage:
+    python mqtt_sniffer.py <printer_ip> <serial_number> <access_code>
+
+Example:
+    python mqtt_sniffer.py 192.168.1.100 0948BB540200427 12345678
+"""
+
+import json
+import ssl
+import sys
+import time
+from datetime import datetime
+
+import paho.mqtt.client as mqtt
+
+
+def on_connect(client, userdata, flags, rc):
+    """Called when connected to the MQTT broker."""
+    if rc == 0:
+        print(f"[{datetime.now().strftime('%H:%M:%S')}] Connected to printer!")
+        serial = userdata["serial"]
+        # Subscribe to all topics for this printer
+        topic_report = f"device/{serial}/report"
+        client.subscribe(topic_report)
+        print(f"[{datetime.now().strftime('%H:%M:%S')}] Subscribed to: {topic_report}")
+        print("-" * 80)
+        print("Listening for MQTT messages... Press Ctrl+C to stop.")
+        print("Now use OrcaSlicer to add a K-profile and the command will be logged here.")
+        print("-" * 80)
+    else:
+        print(f"[{datetime.now().strftime('%H:%M:%S')}] Connection failed with code: {rc}")
+
+
+def on_message(client, userdata, msg):
+    """Called when a message is received."""
+    try:
+        payload = json.loads(msg.payload.decode("utf-8"))
+
+        # Check if this is an extrusion_cali related message
+        is_cali_msg = False
+        command = None
+
+        if "print" in payload:
+            command = payload["print"].get("command", "")
+            if "extrusion_cali" in command:
+                is_cali_msg = True
+
+        # Always log calibration messages with full detail
+        if is_cali_msg:
+            print(f"\n{'='*80}")
+            print(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
+            print(f"Topic: {msg.topic}")
+            print(f"Full payload:")
+            print(json.dumps(payload, indent=2))
+            print(f"{'='*80}\n")
+        else:
+            # For other messages, just show a brief summary
+            if "print" in payload:
+                cmd = payload["print"].get("command", "unknown")
+                # Skip noisy status messages
+                if cmd not in ["push_status"]:
+                    print(f"[{datetime.now().strftime('%H:%M:%S')}] Command: {cmd}")
+
+    except json.JSONDecodeError:
+        print(f"[{datetime.now().strftime('%H:%M:%S')}] Non-JSON message on {msg.topic}")
+    except Exception as e:
+        print(f"[{datetime.now().strftime('%H:%M:%S')}] Error processing message: {e}")
+
+
+def on_disconnect(client, userdata, rc):
+    """Called when disconnected from the MQTT broker."""
+    print(f"[{datetime.now().strftime('%H:%M:%S')}] Disconnected with code: {rc}")
+
+
+def main():
+    if len(sys.argv) != 4:
+        print("Usage: python mqtt_sniffer.py <printer_ip> <serial_number> <access_code>")
+        print("\nExample:")
+        print("  python mqtt_sniffer.py 192.168.1.100 0948BB540200427 12345678")
+        sys.exit(1)
+
+    printer_ip = sys.argv[1]
+    serial_number = sys.argv[2]
+    access_code = sys.argv[3]
+
+    print(f"Connecting to printer at {printer_ip}...")
+    print(f"Serial: {serial_number}")
+
+    # Create MQTT client
+    client = mqtt.Client(userdata={"serial": serial_number})
+    client.username_pw_set("bblp", access_code)
+
+    # Configure TLS
+    ssl_context = ssl.create_default_context()
+    ssl_context.check_hostname = False
+    ssl_context.verify_mode = ssl.CERT_NONE
+    client.tls_set_context(ssl_context)
+
+    # Set callbacks
+    client.on_connect = on_connect
+    client.on_message = on_message
+    client.on_disconnect = on_disconnect
+
+    try:
+        client.connect(printer_ip, 8883, 60)
+        client.loop_forever()
+    except KeyboardInterrupt:
+        print("\n\nStopping sniffer...")
+        client.disconnect()
+    except Exception as e:
+        print(f"Error: {e}")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

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


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


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


+ 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-CKBzHHT0.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-H_ymON9v.css">
+    <script type="module" crossorigin src="/assets/index-Bi9VwPck.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CJM6Rz8E.css">
   </head>
   <body>
     <div id="root"></div>

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