Browse Source

Merge pull request #9 from maziggy/0.1.5

v0.1.4
MartinNYHC 5 months ago
parent
commit
aaf3dbaeb6

+ 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:

+ 16 - 0
backend/app/api/routes/settings.py

@@ -1,4 +1,5 @@
 import shutil
+from pathlib import Path
 
 from fastapi import APIRouter, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -95,6 +96,21 @@ async def reset_settings(db: AsyncSession = Depends(get_db)):
 async def check_ffmpeg():
     """Check if ffmpeg is installed and available."""
     ffmpeg_path = shutil.which("ffmpeg")
+
+    # If not found via PATH, check common installation locations
+    # (systemd services often have limited PATH)
+    if ffmpeg_path is None:
+        common_paths = [
+            "/usr/bin/ffmpeg",
+            "/usr/local/bin/ffmpeg",
+            "/opt/homebrew/bin/ffmpeg",
+            "/snap/bin/ffmpeg",
+        ]
+        for path in common_paths:
+            if Path(path).exists():
+                ffmpeg_path = path
+                break
+
     return {
         "installed": ffmpeg_path is not None,
         "path": ffmpeg_path,

+ 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 ? (

+ 294 - 137
frontend/src/pages/MaintenancePage.tsx

@@ -8,10 +8,8 @@ import {
   Clock,
   Plus,
   Trash2,
-  Settings2,
   ChevronDown,
   ChevronUp,
-  RotateCcw,
   Droplet,
   Flame,
   Ruler,
@@ -19,6 +17,7 @@ import {
   Square,
   Cable,
   Edit3,
+  RotateCcw,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { MaintenanceStatus, PrinterMaintenanceOverview } from '../api/client';
@@ -334,134 +333,266 @@ function PrinterSection({
   );
 }
 
-// Settings modal for managing custom types
-function SettingsModal({
-  onClose,
+// Settings section component - maintenance types and per-printer interval overrides
+function SettingsSection({
+  overview,
   types,
+  onUpdateInterval,
   onAddType,
   onDeleteType,
 }: {
-  onClose: () => void;
+  overview: PrinterMaintenanceOverview[] | undefined;
   types: Array<{ id: number; name: string; default_interval_hours: number; icon: string | null; is_system: boolean }>;
+  onUpdateInterval: (id: number, customInterval: number | null) => void;
   onAddType: (data: { name: string; description?: string; default_interval_hours: number; icon?: string }) => void;
   onDeleteType: (id: number) => void;
 }) {
-  const [name, setName] = useState('');
-  const [interval, setInterval] = useState('100');
-  const [icon, setIcon] = useState('Wrench');
+  const [editingInterval, setEditingInterval] = useState<number | null>(null);
+  const [intervalInput, setIntervalInput] = useState('');
+  const [showAddType, setShowAddType] = useState(false);
+  const [newTypeName, setNewTypeName] = useState('');
+  const [newTypeInterval, setNewTypeInterval] = useState('100');
+  const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
+
+  const handleSaveInterval = (itemId: number, defaultInterval: number) => {
+    const newInterval = parseFloat(intervalInput);
+    if (!isNaN(newInterval) && newInterval > 0) {
+      const customInterval = Math.abs(newInterval - defaultInterval) < 0.01 ? null : newInterval;
+      onUpdateInterval(itemId, customInterval);
+    }
+    setEditingInterval(null);
+  };
 
-  const handleSubmit = (e: React.FormEvent) => {
+  const handleAddType = (e: React.FormEvent) => {
     e.preventDefault();
-    if (name.trim() && parseFloat(interval) > 0) {
+    if (newTypeName.trim() && parseFloat(newTypeInterval) > 0) {
       onAddType({
-        name: name.trim(),
-        default_interval_hours: parseFloat(interval),
-        icon,
+        name: newTypeName.trim(),
+        default_interval_hours: parseFloat(newTypeInterval),
+        icon: newTypeIcon,
       });
-      setName('');
-      setInterval('100');
+      setNewTypeName('');
+      setNewTypeInterval('100');
+      setShowAddType(false);
     }
   };
 
+  const printerItems = overview?.map(p => ({
+    printerId: p.printer_id,
+    printerName: p.printer_name,
+    items: p.maintenance_items.sort((a, b) => a.maintenance_type_id - b.maintenance_type_id),
+  })).sort((a, b) => a.printerName.localeCompare(b.printerName)) || [];
+
+  const systemTypes = types.filter(t => t.is_system);
   const customTypes = types.filter(t => !t.is_system);
 
   return (
-    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
-      <div className="bg-bambu-dark-secondary rounded-lg p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
-        <h3 className="text-lg font-semibold text-white mb-4">Maintenance Settings</h3>
-
-        {/* Existing custom types */}
-        {customTypes.length > 0 && (
-          <div className="mb-6">
-            <h4 className="text-sm text-bambu-gray mb-2">Custom Maintenance Types</h4>
-            <div className="space-y-2">
-              {customTypes.map((type) => {
-                const Icon = getIcon(type.icon);
-                return (
-                  <div key={type.id} className="flex items-center justify-between p-2 bg-bambu-dark rounded">
-                    <div className="flex items-center gap-2">
-                      <Icon className="w-4 h-4 text-bambu-gray" />
-                      <span className="text-white text-sm">{type.name}</span>
-                      <span className="text-bambu-gray text-xs">({type.default_interval_hours}h)</span>
+    <div className="space-y-8">
+      {/* Maintenance Types */}
+      <div>
+        <div className="flex items-center justify-between mb-4">
+          <h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
+          <Button size="sm" variant="secondary" onClick={() => setShowAddType(!showAddType)}>
+            <Plus className="w-4 h-4" />
+            Add Custom Type
+          </Button>
+        </div>
+
+        {/* Add custom type form */}
+        {showAddType && (
+          <Card className="mb-4">
+            <div className="p-4">
+              <form onSubmit={handleAddType}>
+                <div className="flex gap-3 items-end">
+                  <div className="flex-1">
+                    <label className="block text-xs text-bambu-gray mb-1">Name</label>
+                    <input
+                      type="text"
+                      value={newTypeName}
+                      onChange={(e) => setNewTypeName(e.target.value)}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
+                      placeholder="e.g., Replace HEPA Filter"
+                      autoFocus
+                    />
+                  </div>
+                  <div className="w-28">
+                    <label className="block text-xs text-bambu-gray mb-1">Interval (hours)</label>
+                    <input
+                      type="number"
+                      value={newTypeInterval}
+                      onChange={(e) => setNewTypeInterval(e.target.value)}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
+                      min="1"
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-xs text-bambu-gray mb-1">Icon</label>
+                    <div className="flex gap-1">
+                      {Object.keys(iconMap).map((iconName) => {
+                        const IconComp = iconMap[iconName];
+                        return (
+                          <button
+                            key={iconName}
+                            type="button"
+                            onClick={() => setNewTypeIcon(iconName)}
+                            className={`p-2 rounded ${
+                              newTypeIcon === iconName
+                                ? 'bg-bambu-green text-white'
+                                : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                            }`}
+                          >
+                            <IconComp className="w-4 h-4" />
+                          </button>
+                        );
+                      })}
                     </div>
-                    <button
-                      onClick={() => {
-                        if (confirm(`Delete "${type.name}"?`)) {
-                          onDeleteType(type.id);
-                        }
-                      }}
-                      className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400"
-                    >
-                      <Trash2 className="w-4 h-4" />
-                    </button>
                   </div>
-                );
-              })}
+                  <div className="flex gap-2">
+                    <Button type="submit" size="sm" disabled={!newTypeName.trim()}>Add</Button>
+                    <Button type="button" size="sm" variant="secondary" onClick={() => setShowAddType(false)}>Cancel</Button>
+                  </div>
+                </div>
+              </form>
             </div>
-          </div>
+          </Card>
         )}
 
-        {/* Add new type */}
-        <form onSubmit={handleSubmit}>
-          <h4 className="text-sm text-bambu-gray mb-2">Add Custom Type</h4>
-          <div className="flex gap-2 mb-3">
-            <input
-              type="text"
-              value={name}
-              onChange={(e) => setName(e.target.value)}
-              className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
-              placeholder="Name (e.g., Replace HEPA Filter)"
-            />
-            <input
-              type="number"
-              value={interval}
-              onChange={(e) => setInterval(e.target.value)}
-              className="w-20 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
-              placeholder="Hours"
-              min="1"
-            />
-          </div>
-          <div className="flex items-center justify-between">
-            <div className="flex gap-1">
-              {Object.keys(iconMap).map((iconName) => {
-                const IconComp = iconMap[iconName];
-                return (
+        {/* Types grid */}
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
+          {/* System types */}
+          {systemTypes.map((type) => {
+            const Icon = getIcon(type.icon);
+            return (
+              <div key={type.id} className="bg-bambu-dark-secondary rounded-lg p-4 border border-bambu-dark-tertiary">
+                <div className="flex items-center gap-3">
+                  <div className="p-2 bg-bambu-dark rounded-lg">
+                    <Icon className="w-5 h-5 text-bambu-gray" />
+                  </div>
+                  <div className="flex-1 min-w-0">
+                    <div className="text-sm font-medium text-white truncate">{type.name}</div>
+                    <div className="text-xs text-bambu-gray mt-0.5">{type.default_interval_hours}h interval</div>
+                  </div>
+                </div>
+              </div>
+            );
+          })}
+          {/* Custom types */}
+          {customTypes.map((type) => {
+            const Icon = getIcon(type.icon);
+            return (
+              <div key={type.id} className="bg-bambu-dark-secondary rounded-lg p-4 border border-bambu-green/30">
+                <div className="flex items-center gap-3">
+                  <div className="p-2 bg-bambu-green/20 rounded-lg">
+                    <Icon className="w-5 h-5 text-bambu-green" />
+                  </div>
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2">
+                      <span className="text-sm font-medium text-white truncate">{type.name}</span>
+                      <span className="px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded">Custom</span>
+                    </div>
+                    <div className="text-xs text-bambu-gray mt-0.5">{type.default_interval_hours}h interval</div>
+                  </div>
                   <button
-                    key={iconName}
-                    type="button"
-                    onClick={() => setIcon(iconName)}
-                    className={`p-1.5 rounded ${
-                      icon === iconName
-                        ? 'bg-bambu-green text-white'
-                        : 'bg-bambu-dark text-bambu-gray hover:text-white'
-                    }`}
+                    onClick={() => {
+                      if (confirm(`Delete "${type.name}"?`)) {
+                        onDeleteType(type.id);
+                      }
+                    }}
+                    className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
                   >
-                    <IconComp className="w-4 h-4" />
+                    <Trash2 className="w-4 h-4" />
                   </button>
-                );
-              })}
-            </div>
-            <Button type="submit" size="sm" disabled={!name.trim()}>
-              <Plus className="w-4 h-4" />
-              Add
-            </Button>
-          </div>
-        </form>
-
-        <div className="mt-6 pt-4 border-t border-bambu-dark-tertiary flex justify-end">
-          <Button variant="secondary" onClick={onClose}>
-            Close
-          </Button>
+                </div>
+              </div>
+            );
+          })}
         </div>
       </div>
+
+      {/* Per-printer interval overrides */}
+      {printerItems.length > 0 && (
+        <div>
+          <h2 className="text-lg font-semibold text-white mb-2">Interval Overrides</h2>
+          <p className="text-sm text-bambu-gray mb-4">
+            Set custom intervals per printer.
+          </p>
+          <div className="space-y-3">
+            {printerItems.map((printer) => (
+              <Card key={printer.printerId}>
+                <div className="p-4">
+                  <h3 className="text-sm font-medium text-white mb-3">{printer.printerName}</h3>
+                  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
+                    {printer.items.map((item) => {
+                      const Icon = getIcon(item.maintenance_type_icon);
+                      const typeInfo = types.find(t => t.id === item.maintenance_type_id);
+                      const defaultInterval = typeInfo?.default_interval_hours || item.interval_hours;
+                      const isEditing = editingInterval === item.id;
+
+                      return (
+                        <div key={item.id} className="flex items-center gap-2 p-2 bg-bambu-dark rounded-lg">
+                          <Icon className="w-4 h-4 text-bambu-gray shrink-0" />
+                          <span className="text-xs text-bambu-gray flex-1 truncate">{item.maintenance_type_name}</span>
+
+                          {isEditing ? (
+                            <div className="flex items-center gap-1">
+                              <input
+                                type="number"
+                                value={intervalInput}
+                                onChange={(e) => setIntervalInput(e.target.value)}
+                                onKeyDown={(e) => {
+                                  if (e.key === 'Enter') handleSaveInterval(item.id, defaultInterval);
+                                  if (e.key === 'Escape') setEditingInterval(null);
+                                }}
+                                className="w-16 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
+                                min="1"
+                                autoFocus
+                              />
+                              <Button size="sm" onClick={() => handleSaveInterval(item.id, defaultInterval)}>OK</Button>
+                            </div>
+                          ) : (
+                            <button
+                              onClick={() => {
+                                setEditingInterval(item.id);
+                                setIntervalInput(item.interval_hours.toString());
+                              }}
+                              className="px-2 py-1 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green rounded text-xs font-medium text-white transition-colors"
+                            >
+                              {item.interval_hours}h
+                              <Edit3 className="w-3 h-3 inline ml-1.5 text-bambu-gray" />
+                            </button>
+                          )}
+                        </div>
+                      );
+                    })}
+                  </div>
+                </div>
+              </Card>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {printerItems.length === 0 && (
+        <Card>
+          <CardContent className="text-center py-12">
+            <Clock className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
+            <p className="text-bambu-gray">No printers configured</p>
+            <p className="text-sm text-bambu-gray/70 mt-1">
+              Add printers to configure maintenance intervals
+            </p>
+          </CardContent>
+        </Card>
+      )}
     </div>
   );
 }
 
+type TabType = 'status' | 'settings';
+
 export function MaintenancePage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const [showSettings, setShowSettings] = useState(false);
+  const [activeTab, setActiveTab] = useState<TabType>('status');
 
   const { data: overview, isLoading } = useQuery({
     queryKey: ['maintenanceOverview'],
@@ -561,52 +692,78 @@ export function MaintenancePage() {
   return (
     <div className="p-8">
       {/* Header */}
-      <div className="mb-6 flex items-center justify-between">
-        <div>
-          <h1 className="text-2xl font-bold text-white">Maintenance</h1>
-          <p className="text-bambu-gray text-sm">
-            {totalDue > 0 && <span className="text-red-400">{totalDue} tasks overdue</span>}
-            {totalDue > 0 && totalWarning > 0 && ' · '}
-            {totalWarning > 0 && <span className="text-yellow-400">{totalWarning} due soon</span>}
-            {totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'}
-          </p>
-        </div>
-        <Button variant="secondary" onClick={() => setShowSettings(true)}>
-          <Settings2 className="w-4 h-4" />
-          Settings
-        </Button>
+      <div className="mb-6">
+        <h1 className="text-2xl font-bold text-white">Maintenance</h1>
+        <p className="text-bambu-gray text-sm">
+          {activeTab === 'status' ? (
+            <>
+              {totalDue > 0 && <span className="text-red-400">{totalDue} tasks overdue</span>}
+              {totalDue > 0 && totalWarning > 0 && ' · '}
+              {totalWarning > 0 && <span className="text-yellow-400">{totalWarning} due soon</span>}
+              {totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'}
+            </>
+          ) : (
+            'Configure maintenance types and intervals'
+          )}
+        </p>
       </div>
 
-      {/* Printers - sorted alphabetically */}
-      <div className="space-y-4">
-        {overview && overview.length > 0 ? (
-          [...overview].sort((a, b) => a.printer_name.localeCompare(b.printer_name)).map((printerOverview) => (
-            <PrinterSection
-              key={printerOverview.printer_id}
-              overview={printerOverview}
-              onPerform={handlePerform}
-              onToggle={handleToggle}
-              onSetHours={handleSetHours}
-            />
-          ))
-        ) : (
-          <Card>
-            <CardContent className="text-center py-12">
-              <Wrench className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
-              <p className="text-bambu-gray">No printers configured</p>
-              <p className="text-sm text-bambu-gray/70 mt-1">
-                Add printers to start tracking maintenance
-              </p>
-            </CardContent>
-          </Card>
-        )}
+      {/* Tabs */}
+      <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
+        <button
+          onClick={() => setActiveTab('status')}
+          className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
+            activeTab === 'status'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray border-transparent hover:text-white'
+          }`}
+        >
+          Status
+        </button>
+        <button
+          onClick={() => setActiveTab('settings')}
+          className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
+            activeTab === 'settings'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray border-transparent hover:text-white'
+          }`}
+        >
+          Settings
+        </button>
       </div>
 
-      {/* Settings modal */}
-      {showSettings && types && (
-        <SettingsModal
-          onClose={() => setShowSettings(false)}
-          types={types}
+      {/* Tab content */}
+      {activeTab === 'status' ? (
+        <div className="space-y-4">
+          {overview && overview.length > 0 ? (
+            [...overview].sort((a, b) => a.printer_name.localeCompare(b.printer_name)).map((printerOverview) => (
+              <PrinterSection
+                key={printerOverview.printer_id}
+                overview={printerOverview}
+                onPerform={handlePerform}
+                onToggle={handleToggle}
+                onSetHours={handleSetHours}
+              />
+            ))
+          ) : (
+            <Card>
+              <CardContent className="text-center py-12">
+                <Wrench className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
+                <p className="text-bambu-gray">No printers configured</p>
+                <p className="text-sm text-bambu-gray/70 mt-1">
+                  Add printers to start tracking maintenance
+                </p>
+              </CardContent>
+            </Card>
+          )}
+        </div>
+      ) : (
+        <SettingsSection
+          overview={overview}
+          types={types || []}
+          onUpdateInterval={(id, customInterval) =>
+            updateMutation.mutate({ id, data: { custom_interval_hours: customInterval } })
+          }
           onAddType={(data) => addTypeMutation.mutate(data)}
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
         />

+ 11 - 1
frontend/src/pages/PrintersPage.tsx

@@ -87,6 +87,7 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
 interface PrinterMaintenanceInfo {
   due_count: number;
   warning_count: number;
+  total_print_hours: number;
 }
 
 function PrinterCard({
@@ -175,7 +176,15 @@ function PrinterCard({
         <div className="flex items-start justify-between mb-4">
           <div>
             <h3 className="text-lg font-semibold text-white">{printer.name}</h3>
-            <p className="text-sm text-bambu-gray">{printer.model || 'Unknown Model'}</p>
+            <p className="text-sm text-bambu-gray">
+              {printer.model || 'Unknown Model'}
+              {maintenanceInfo && maintenanceInfo.total_print_hours > 0 && (
+                <span className="ml-2 text-bambu-gray">
+                  <Clock className="w-3 h-3 inline-block mr-1" />
+                  {Math.round(maintenanceInfo.total_print_hours)}h
+                </span>
+              )}
+            </p>
           </div>
           <div className="flex items-center gap-2">
             <span
@@ -726,6 +735,7 @@ export function PrintersPage() {
       acc[overview.printer_id] = {
         due_count: overview.due_count,
         warning_count: overview.warning_count,
+        total_print_hours: overview.total_print_hours,
       };
       return acc;
     },

+ 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-4olH2ask.css


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-soeWpmRO.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-CKBzHHT0.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-H_ymON9v.css">
+    <script type="module" crossorigin src="/assets/index-soeWpmRO.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-4olH2ask.css">
   </head>
   <body>
     <div id="root"></div>

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