Browse Source

Wiring up calibration modal

Martin Ziegler 5 months ago
parent
commit
b6af768482

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

@@ -25,6 +25,7 @@ from backend.app.schemas.printer import (
     PrintOptionsResponse,
 )
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.bambu_mqtt import get_stage_name
 from backend.app.services.bambu_ftp import (
     download_file_try_paths_async,
     list_files_async,
@@ -237,6 +238,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         ipcam=state.ipcam,
         nozzles=nozzles,
         print_options=print_options,
+        stg_cur=state.stg_cur,
+        stg_cur_name=get_stage_name(state.stg_cur) if state.stg_cur >= 0 else None,
+        stg=state.stg,
     )
 
 
@@ -634,3 +638,62 @@ async def set_print_option(
         "print_halt": print_halt,
         "sensitivity": sensitivity,
     }
+
+
+# ============================================
+# Calibration
+# ============================================
+
+@router.post("/{printer_id}/calibration")
+async def start_calibration(
+    printer_id: int,
+    bed_leveling: bool = False,
+    vibration: bool = False,
+    motor_noise: bool = False,
+    nozzle_offset: bool = False,
+    high_temp_heatbed: bool = False,
+    db: AsyncSession = Depends(get_db),
+):
+    """Start printer calibration with selected options.
+
+    At least one option must be selected.
+
+    Options:
+    - bed_leveling: Run bed leveling calibration
+    - vibration: Run vibration compensation calibration
+    - motor_noise: Run motor noise cancellation calibration
+    - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
+    - high_temp_heatbed: Run high-temperature heatbed calibration
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client or not client.state.connected:
+        raise HTTPException(400, "Printer not connected")
+
+    # Check that at least one option is selected
+    if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):
+        raise HTTPException(400, "At least one calibration option must be selected")
+
+    success = client.start_calibration(
+        bed_leveling=bed_leveling,
+        vibration=vibration,
+        motor_noise=motor_noise,
+        nozzle_offset=nozzle_offset,
+        high_temp_heatbed=high_temp_heatbed,
+    )
+
+    if not success:
+        raise HTTPException(500, "Failed to send calibration command to printer")
+
+    return {
+        "success": True,
+        "bed_leveling": bed_leveling,
+        "vibration": vibration,
+        "motor_noise": motor_noise,
+        "nozzle_offset": nozzle_offset,
+        "high_temp_heatbed": high_temp_heatbed,
+    }

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

@@ -108,3 +108,7 @@ class PrinterStatus(BaseModel):
     ipcam: bool = False  # Live view enabled
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
     print_options: PrintOptionsResponse | None = None  # AI detection and print options
+    # Calibration stage tracking
+    stg_cur: int = -1  # Current stage number (-1 = not calibrating)
+    stg_cur_name: str | None = None  # Human-readable current stage name
+    stg: list[int] = []  # List of stage numbers in calibration sequence

+ 231 - 5
backend/app/services/bambu_mqtt.py

@@ -100,6 +100,86 @@ class PrinterState:
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
     print_options: PrintOptions = field(default_factory=PrintOptions)
+    # Calibration stage tracking (from stg_cur and stg fields)
+    stg_cur: int = -1  # Current stage index (-1 = not calibrating)
+    stg: list = field(default_factory=list)  # List of stages to execute
+
+
+# Stage name mapping from BambuStudio DeviceManager.cpp
+STAGE_NAMES = {
+    0: "Printing",
+    1: "Auto bed leveling",
+    2: "Heatbed preheating",
+    3: "Vibration compensation",
+    4: "Changing filament",
+    5: "M400 pause",
+    6: "Paused (filament ran out)",
+    7: "Heating nozzle",
+    8: "Calibrating dynamic flow",
+    9: "Scanning bed surface",
+    10: "Inspecting first layer",
+    11: "Identifying build plate type",
+    12: "Calibrating Micro Lidar",
+    13: "Homing toolhead",
+    14: "Cleaning nozzle tip",
+    15: "Checking extruder temperature",
+    16: "Paused by the user",
+    17: "Pause (front cover fall off)",
+    18: "Calibrating the micro lidar",
+    19: "Calibrating flow ratio",
+    20: "Pause (nozzle temperature malfunction)",
+    21: "Pause (heatbed temperature malfunction)",
+    22: "Filament unloading",
+    23: "Pause (step loss)",
+    24: "Filament loading",
+    25: "Motor noise cancellation",
+    26: "Pause (AMS offline)",
+    27: "Pause (low speed of the heatbreak fan)",
+    28: "Pause (chamber temperature control problem)",
+    29: "Cooling chamber",
+    30: "Pause (Gcode inserted by user)",
+    31: "Motor noise showoff",
+    32: "Pause (nozzle clumping)",
+    33: "Pause (cutter error)",
+    34: "Pause (first layer error)",
+    35: "Pause (nozzle clog)",
+    36: "Measuring motion precision",
+    37: "Enhancing motion precision",
+    38: "Measure motion accuracy",
+    39: "Nozzle offset calibration",
+    40: "High temperature auto bed leveling",
+    41: "Auto Check: Quick Release Lever",
+    42: "Auto Check: Door and Upper Cover",
+    43: "Laser Calibration",
+    44: "Auto Check: Platform",
+    45: "Confirming BirdsEye Camera location",
+    46: "Calibrating BirdsEye Camera",
+    47: "Auto bed leveling - phase 1",
+    48: "Auto bed leveling - phase 2",
+    49: "Heating chamber",
+    50: "Cooling heatbed",
+    51: "Printing calibration lines",
+    52: "Auto Check: Material",
+    53: "Live View Camera Calibration",
+    54: "Waiting for heatbed temperature",
+    55: "Auto Check: Material Position",
+    56: "Cutting Module Offset Calibration",
+    57: "Measuring Surface",
+    58: "Thermal Preconditioning",
+    59: "Homing Blade Holder",
+    60: "Calibrating Camera Offset",
+    61: "Calibrating Blade Holder Position",
+    62: "Hotend Pick and Place Test",
+    63: "Waiting for Chamber temperature",
+    64: "Preparing Hotend",
+    65: "Calibrating nozzle clumping detection",
+    66: "Purifying the chamber air",
+}
+
+
+def get_stage_name(stage: int) -> str:
+    """Get human-readable stage name from stage number."""
+    return STAGE_NAMES.get(stage, f"Unknown stage ({stage})")
 
 
 class BambuMQTTClient:
@@ -533,12 +613,27 @@ class BambuMQTTClient:
         if "total_layer_num" in data:
             self.state.total_layers = int(data["total_layer_num"])
 
+        # Calibration stage tracking
+        if "stg_cur" in data:
+            new_stg = data["stg_cur"]
+            if new_stg != self.state.stg_cur:
+                logger.info(
+                    f"[{self.serial_number}] Calibration stage changed: "
+                    f"{self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
+                )
+            self.state.stg_cur = new_stg
+        if "stg" in data:
+            self.state.stg = data["stg"] if isinstance(data["stg"], list) else []
+
         # Temperature data
         temps = {}
-        # Log all temperature-related fields for debugging (only when we have temp data)
-        temp_fields = {k: v for k, v in data.items() if 'temp' in k.lower() or 'nozzle' in k.lower()}
-        if temp_fields and not hasattr(self, '_temp_fields_logged'):
-            logger.info(f"[{self.serial_number}] Temperature fields in MQTT data: {temp_fields}")
+        # Log all fields for debugging dual-nozzle temperature discovery (only once)
+        if "bed_temper" in data and not hasattr(self, '_temp_fields_logged'):
+            temp_fields = {k: v for k, v in data.items() if 'temp' in k.lower() or 'chamber' in k.lower()}
+            logger.info(f"[{self.serial_number}] Temperature-related fields: {temp_fields}")
+            # Log ALL keys in print data for H2D temperature discovery
+            all_keys = sorted(data.keys())
+            logger.info(f"[{self.serial_number}] ALL print data keys ({len(all_keys)}): {all_keys}")
             self._temp_fields_logged = True
 
         # Log nozzle hardware info fields (once)
@@ -571,8 +666,57 @@ class BambuMQTTClient:
             temps["nozzle_target"] = float(data["left_nozzle_target_temper"])
         if "chamber_temper" in data:
             temps["chamber"] = float(data["chamber_temper"])
+        # H2D series: Chamber temp is in info.temp (directly in °C)
+        try:
+            if "info" in data and isinstance(data["info"], dict):
+                info_temp = data["info"].get("temp")
+                if info_temp is not None and "chamber" not in temps:
+                    temps["chamber"] = float(info_temp)
+            # H2D series: Dual extruder temps are in device.extruder.info array
+            # Temperature values are encoded as fixed-point (value / 65536 = °C)
+            if "device" in data and isinstance(data["device"], dict):
+                device = data["device"]
+                # Parse dual extruder temperatures
+                extruder_data = device.get("extruder", {})
+                extruder_info = extruder_data.get("info", [])
+                if isinstance(extruder_info, list) and len(extruder_info) >= 1:
+                    # Log extruder info structure for debugging (once)
+                    if not getattr(self, '_extruder_info_logged', False):
+                        logger.debug(f"[{self.serial_number}] H2D extruder info[0]: {extruder_info[0]}")
+                        if len(extruder_info) >= 2:
+                            logger.debug(f"[{self.serial_number}] H2D extruder info[1]: {extruder_info[1]}")
+                        self._extruder_info_logged = True
+                    # Left nozzle (extruder 0) - temp is already in Celsius
+                    if "nozzle" not in temps and "temp" in extruder_info[0]:
+                        temp_val = extruder_info[0]["temp"]
+                        if -50 < temp_val < 500:  # Valid temp range
+                            temps["nozzle"] = float(temp_val)
+                    # Left nozzle target temp - star field, but 65535/65279 means "not set"
+                    if "nozzle_target" not in temps:
+                        star = extruder_info[0].get("star")
+                        if star is not None and 0 <= star < 500:  # Valid temp range
+                            temps["nozzle_target"] = float(star)
+                    # Right nozzle (extruder 1) - only for dual nozzle printers
+                    if len(extruder_info) >= 2 and "temp" in extruder_info[1]:
+                        temp_val = extruder_info[1]["temp"]
+                        if -50 < temp_val < 500:  # Valid temp range
+                            temps["nozzle_2"] = float(temp_val)
+                    # Right nozzle target temp - star field, but 65535/65279 means "not set"
+                    if len(extruder_info) >= 2:
+                        star = extruder_info[1].get("star")
+                        if star is not None and 0 <= star < 500:  # Valid temp range
+                            temps["nozzle_2_target"] = float(star)
+                # Parse chamber temp from device.ctc.info.temp if not already set
+                ctc_data = device.get("ctc", {})
+                ctc_info = ctc_data.get("info", {})
+                if "temp" in ctc_info and "chamber" not in temps:
+                    temps["chamber"] = float(ctc_info["temp"])
+        except Exception as e:
+            logger.warning(f"[{self.serial_number}] Error parsing H2D temperatures: {e}")
         if temps:
-            self.state.temperatures = temps
+            # Merge new temps into existing, preserving valid values when new ones are filtered out
+            for key, value in temps.items():
+                self.state.temperatures[key] = value
 
         # Parse HMS (Health Management System) errors
         if "hms" in data:
@@ -655,6 +799,20 @@ class BambuMQTTClient:
         if "nozzle_diameter_2" in data:
             self.state.nozzles[1].nozzle_diameter = str(data["nozzle_diameter_2"])
 
+        # H2D series: Nozzle hardware info is in device.nozzle.info array
+        if "device" in data and isinstance(data["device"], dict):
+            device = data["device"]
+            nozzle_data = device.get("nozzle", {})
+            nozzle_info = nozzle_data.get("info", [])
+            if isinstance(nozzle_info, list):
+                for nozzle in nozzle_info:
+                    idx = nozzle.get("id", 0)
+                    if idx < len(self.state.nozzles):
+                        if "type" in nozzle and nozzle["type"]:
+                            self.state.nozzles[idx].nozzle_type = str(nozzle["type"])
+                        if "diameter" in nozzle:
+                            self.state.nozzles[idx].nozzle_diameter = str(nozzle["diameter"])
+
         # Preserve AMS and vt_tray data when updating raw_data
         ams_data = self.state.raw_data.get("ams")
         vt_tray_data = self.state.raw_data.get("vt_tray")
@@ -1029,6 +1187,74 @@ class BambuMQTTClient:
 
         return True
 
+    def start_calibration(
+        self,
+        bed_leveling: bool = False,
+        vibration: bool = False,
+        motor_noise: bool = False,
+        nozzle_offset: bool = False,
+        high_temp_heatbed: bool = False,
+    ) -> bool:
+        """Start printer calibration with selected options.
+
+        Args:
+            bed_leveling: Run bed leveling calibration
+            vibration: Run vibration compensation calibration
+            motor_noise: Run motor noise cancellation calibration
+            nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
+            high_temp_heatbed: Run high-temperature heatbed calibration
+
+        Returns:
+            True if command was sent, False if not connected
+        """
+        if not self._client or not self.state.connected:
+            return False
+
+        # Build calibration bitmask based on OrcaSlicer DeviceManager.cpp
+        # Bit 0: xcam_cali (not exposed in UI)
+        # Bit 1: bed_leveling
+        # Bit 2: vibration
+        # Bit 3: motor_noise
+        # Bit 4: nozzle_cali
+        # Bit 5: bed_cali (high-temp heatbed)
+        # Bit 6: clumppos_cali (not exposed in UI)
+        option = 0
+        if bed_leveling:
+            option |= 1 << 1
+        if vibration:
+            option |= 1 << 2
+        if motor_noise:
+            option |= 1 << 3
+        if nozzle_offset:
+            option |= 1 << 4
+        if high_temp_heatbed:
+            option |= 1 << 5
+
+        if option == 0:
+            logger.warning(f"[{self.serial_number}] No calibration options selected")
+            return False
+
+        self._sequence_id += 1
+
+        command = {
+            "print": {
+                "command": "calibration",
+                "sequence_id": str(self._sequence_id),
+                "option": option,
+            }
+        }
+
+        command_json = json.dumps(command)
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        logger.info(
+            f"[{self.serial_number}] Starting calibration: "
+            f"bed_leveling={bed_leveling}, vibration={vibration}, "
+            f"motor_noise={motor_noise}, nozzle_offset={nozzle_offset}, "
+            f"high_temp_heatbed={high_temp_heatbed} (option={option})"
+        )
+
+        return True
+
     def disconnect(self):
         """Disconnect from the printer."""
         if self._client:

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

@@ -111,6 +111,10 @@ export interface PrinterStatus {
   ipcam: boolean;  // Live view enabled
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
   print_options: PrintOptions | null;  // AI detection and print options
+  // Calibration stage tracking
+  stg_cur: number;  // Current stage number (-1 = not calibrating)
+  stg_cur_name: string | null;  // Human-readable current stage name
+  stg: number[];  // List of stage numbers in calibration sequence
 }
 
 export interface PrinterCreate {
@@ -1235,4 +1239,33 @@ export const api = {
       method: 'POST',
     });
   },
+
+  // Calibration
+  startCalibration: (
+    printerId: number,
+    options: {
+      bed_leveling?: boolean;
+      vibration?: boolean;
+      motor_noise?: boolean;
+      nozzle_offset?: boolean;
+      high_temp_heatbed?: boolean;
+    }
+  ) => {
+    const params = new URLSearchParams();
+    if (options.bed_leveling) params.append('bed_leveling', 'true');
+    if (options.vibration) params.append('vibration', 'true');
+    if (options.motor_noise) params.append('motor_noise', 'true');
+    if (options.nozzle_offset) params.append('nozzle_offset', 'true');
+    if (options.high_temp_heatbed) params.append('high_temp_heatbed', 'true');
+    return request<{
+      success: boolean;
+      bed_leveling: boolean;
+      vibration: boolean;
+      motor_noise: boolean;
+      nozzle_offset: boolean;
+      high_temp_heatbed: boolean;
+    }>(`/printers/${printerId}/calibration?${params}`, {
+      method: 'POST',
+    });
+  },
 };

+ 3 - 2
frontend/src/components/control/BedControls.tsx

@@ -7,10 +7,11 @@ import { ConfirmModal } from '../ConfirmModal';
 interface BedControlsProps {
   printerId: number;
   status: PrinterStatus | null | undefined;
+  disabled?: boolean;
 }
 
-export function BedControls({ printerId, status }: BedControlsProps) {
-  const isConnected = status?.connected ?? false;
+export function BedControls({ printerId, status, disabled = false }: BedControlsProps) {
+  const isConnected = (status?.connected ?? false) && !disabled;
 
   const [confirmModal, setConfirmModal] = useState<{
     token: string;

+ 424 - 0
frontend/src/components/control/CalibrationModal.tsx

@@ -0,0 +1,424 @@
+import { useEffect, useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { Printer, PrinterStatus } from '../../api/client';
+import { X, Loader2, AlertTriangle, Check } from 'lucide-react';
+import { Card, CardContent } from '../Card';
+
+interface CalibrationModalProps {
+  printer: Printer;
+  status: PrinterStatus | null | undefined;
+  onClose: () => void;
+}
+
+// Calibration stages that indicate active calibration
+const CALIBRATION_STAGES = new Set([1, 3, 13, 25, 39, 40, 47, 48, 50]);
+
+// Checkbox component matching Bambu Studio style
+function Checkbox({
+  checked,
+  onChange,
+  disabled,
+}: {
+  checked: boolean;
+  onChange: (checked: boolean) => void;
+  disabled?: boolean;
+}) {
+  return (
+    <button
+      onClick={() => !disabled && onChange(!checked)}
+      disabled={disabled}
+      className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
+        checked
+          ? 'bg-bambu-green border-bambu-green'
+          : 'bg-transparent border-bambu-gray'
+      } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
+    >
+      {checked && (
+        <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
+          <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+        </svg>
+      )}
+    </button>
+  );
+}
+
+// Timeline step component - matches Bambu Studio style
+function TimelineStep({
+  step,
+  name,
+  isActive,
+  isComplete,
+  isLast,
+}: {
+  step: number;
+  name: string;
+  isActive: boolean;
+  isComplete: boolean;
+  isLast: boolean;
+}) {
+  return (
+    <div className="flex items-start gap-3">
+      {/* Circle and line container */}
+      <div className="flex flex-col items-center">
+        {/* Number circle */}
+        <div
+          className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
+            isActive || isComplete
+              ? 'bg-bambu-green text-white'
+              : 'bg-bambu-green text-white'
+          }`}
+        >
+          {step}
+        </div>
+        {/* Vertical connecting line */}
+        {!isLast && (
+          <div className={`w-0.5 h-6 ${isComplete ? 'bg-bambu-green' : 'bg-bambu-gray/30'}`} />
+        )}
+      </div>
+      {/* Step name */}
+      <span
+        className={`text-sm pt-0.5 ${
+          isActive ? 'text-white font-semibold' : 'text-bambu-gray'
+        }`}
+      >
+        {name}
+      </span>
+    </div>
+  );
+}
+
+export function CalibrationModal({ printer, status, onClose }: CalibrationModalProps) {
+  const isConnected = status?.connected ?? false;
+  const isDualNozzle = printer.nozzle_count === 2;
+  const currentStage = status?.stg_cur ?? -1;
+
+  // Track if we've started calibration (to switch to progress view)
+  const [calibrationStarted, setCalibrationStarted] = useState(false);
+  // Track if we've seen the printer actually enter calibration mode
+  const [seenCalibrating, setSeenCalibrating] = useState(false);
+  // Track if calibration has completed
+  const [calibrationCompleted, setCalibrationCompleted] = useState(false);
+
+  // Calibration options state - restore from localStorage if calibration is in progress
+  const storageKey = `calibration_options_${printer.id}`;
+  const savedOptions = typeof window !== 'undefined' ? localStorage.getItem(storageKey) : null;
+  const parsedOptions = savedOptions ? JSON.parse(savedOptions) : null;
+
+  const [bedLeveling, setBedLeveling] = useState(parsedOptions?.bedLeveling ?? true);
+  const [vibration, setVibration] = useState(parsedOptions?.vibration ?? true);
+  const [motorNoise, setMotorNoise] = useState(parsedOptions?.motorNoise ?? true);
+  const [nozzleOffset, setNozzleOffset] = useState(parsedOptions?.nozzleOffset ?? isDualNozzle);
+  const [highTempHeatbed, setHighTempHeatbed] = useState(parsedOptions?.highTempHeatbed ?? false);
+  // Track if we've initialized based on calibration state
+  const [initialized, setInitialized] = useState(false);
+
+  // Detect if printer is currently calibrating
+  // Check both stg_cur being a calibration stage AND state being RUNNING
+  // (printer may keep stg_cur at last calibration stage after completion)
+  const printerState = status?.state;
+  const isCalibrating = CALIBRATION_STAGES.has(currentStage) && printerState === 'RUNNING';
+
+  // If calibration is already in progress when modal opens, set tracking state
+  // Checkbox values are preserved from localStorage
+  useEffect(() => {
+    if (!initialized && isCalibrating) {
+      setSeenCalibrating(true);
+      setCalibrationStarted(true);
+      setInitialized(true);
+    } else if (!initialized && !isCalibrating) {
+      setInitialized(true);
+    }
+  }, [initialized, isCalibrating]);
+
+  // Track when printer actually enters calibration mode
+  useEffect(() => {
+    if (isCalibrating && !seenCalibrating) {
+      setSeenCalibrating(true);
+      setCalibrationCompleted(false);
+    }
+  }, [isCalibrating, seenCalibrating]);
+
+  // Auto-detect if calibration was started externally (e.g., from touchscreen)
+  useEffect(() => {
+    if (isCalibrating && !calibrationStarted) {
+      setCalibrationStarted(true);
+    }
+  }, [isCalibrating, calibrationStarted]);
+
+  // Detect when calibration completes:
+  // - Must have seen calibration actually running (seenCalibrating is true)
+  // - Now isCalibrating is false (stg_cur left calibration stages OR state is no longer RUNNING)
+  useEffect(() => {
+    if (seenCalibrating && !isCalibrating && !calibrationCompleted) {
+      setCalibrationCompleted(true);
+    }
+  }, [seenCalibrating, isCalibrating, calibrationCompleted]);
+
+  // Reset function to allow starting a new calibration
+  const resetCalibration = () => {
+    localStorage.removeItem(storageKey);
+    setCalibrationStarted(false);
+    setSeenCalibrating(false);
+    setCalibrationCompleted(false);
+    // Reset to defaults
+    setBedLeveling(true);
+    setVibration(true);
+    setMotorNoise(true);
+    setNozzleOffset(isDualNozzle);
+    setHighTempHeatbed(false);
+  };
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Start calibration mutation
+  const calibrationMutation = useMutation({
+    mutationFn: () =>
+      api.startCalibration(printer.id, {
+        bed_leveling: bedLeveling,
+        vibration: vibration,
+        motor_noise: motorNoise,
+        nozzle_offset: nozzleOffset,
+        high_temp_heatbed: highTempHeatbed,
+      }),
+    onSuccess: () => {
+      // Save selected options to localStorage so they persist across modal close/open
+      localStorage.setItem(storageKey, JSON.stringify({
+        bedLeveling, vibration, motorNoise, nozzleOffset, highTempHeatbed
+      }));
+      setCalibrationStarted(true);
+    },
+  });
+
+  const hasSelection = bedLeveling || vibration || motorNoise || nozzleOffset || highTempHeatbed;
+  const canStart = isConnected && hasSelection && !calibrationMutation.isPending && !isCalibrating && !calibrationCompleted;
+
+  // Build expected calibration flow based on selections
+  // These are in the typical order the printer performs them
+  const expectedFlow: { name: string; stages: number[] }[] = [];
+  expectedFlow.push({ name: 'Homing toolhead', stages: [13] });
+  if (bedLeveling || highTempHeatbed) {
+    expectedFlow.push({ name: 'Cooling heatbed', stages: [50] });
+  }
+  if (bedLeveling) {
+    expectedFlow.push({ name: 'Auto bed leveling - phase 1', stages: [1, 47] });
+  }
+  if (motorNoise) {
+    expectedFlow.push({ name: 'Motor noise cancellation', stages: [25] });
+  }
+  if (vibration) {
+    expectedFlow.push({ name: 'Vibration compensation', stages: [3] });
+  }
+  if (bedLeveling) {
+    expectedFlow.push({ name: 'Auto bed leveling - phase 2', stages: [48] });
+  }
+  if (isDualNozzle && nozzleOffset) {
+    expectedFlow.push({ name: 'Nozzle offset calibration', stages: [39] });
+  }
+  if (highTempHeatbed) {
+    expectedFlow.push({ name: 'High-temp heatbed calibration', stages: [40] });
+  }
+
+  // Find current step index
+  const currentStepIndex = expectedFlow.findIndex((step) =>
+    step.stages.includes(currentStage)
+  );
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <Card className="w-full max-w-3xl max-h-[90vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-0 flex flex-col h-full">
+          {/* Header */}
+          <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
+            <span className="text-sm font-medium text-white">Calibration</span>
+            <button
+              onClick={onClose}
+              className="p-1 rounded text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white"
+            >
+              <X className="w-4 h-4" />
+            </button>
+          </div>
+
+          {/* Content */}
+          <div className="flex-1 overflow-y-auto p-6">
+            {!isConnected && (
+              <div className="flex items-center gap-2 p-3 mb-4 bg-red-500/20 border border-red-500/50 rounded text-red-400">
+                <AlertTriangle className="w-4 h-4" />
+                <span className="text-sm">Printer not connected. Calibration cannot be started.</span>
+              </div>
+            )}
+
+            <div className="grid grid-cols-2 gap-8">
+              {/* Left column - Calibration step selection */}
+              <div>
+                <h3 className="text-base font-semibold text-white mb-4">Calibration step selection</h3>
+                <div className="space-y-4">
+                  {/* Bed leveling */}
+                  <div className="flex items-center gap-3">
+                    <Checkbox
+                      checked={bedLeveling}
+                      onChange={setBedLeveling}
+                      disabled={!isConnected || isCalibrating}
+                    />
+                    <span className="text-sm text-white">Bed leveling</span>
+                  </div>
+
+                  {/* Vibration compensation */}
+                  <div className="flex items-center gap-3">
+                    <Checkbox
+                      checked={vibration}
+                      onChange={setVibration}
+                      disabled={!isConnected || isCalibrating}
+                    />
+                    <span className="text-sm text-white">Vibration compensation</span>
+                  </div>
+
+                  {/* Motor noise cancellation */}
+                  <div className="flex items-center gap-3">
+                    <Checkbox
+                      checked={motorNoise}
+                      onChange={setMotorNoise}
+                      disabled={!isConnected || isCalibrating}
+                    />
+                    <span className="text-sm text-white">Motor noise cancellation</span>
+                  </div>
+
+                  {/* Nozzle offset calibration - only for dual nozzle printers */}
+                  {isDualNozzle && (
+                    <div className="flex items-center gap-3">
+                      <Checkbox
+                        checked={nozzleOffset}
+                        onChange={setNozzleOffset}
+                        disabled={!isConnected || isCalibrating}
+                      />
+                      <span className="text-sm text-white">Nozzle offset calibration</span>
+                    </div>
+                  )}
+
+                  {/* High-temperature Heatbed Calibration */}
+                  <div className="flex items-center gap-3">
+                    <Checkbox
+                      checked={highTempHeatbed}
+                      onChange={setHighTempHeatbed}
+                      disabled={!isConnected || isCalibrating}
+                    />
+                    <span className="text-sm text-white whitespace-nowrap">High-temperature Heatbed Calibration</span>
+                  </div>
+                </div>
+
+                {/* Calibration program description */}
+                <div className="mt-6">
+                  <h4 className="text-sm font-semibold text-white mb-2">Calibration program</h4>
+                  <p className="text-xs text-bambu-gray">
+                    The calibration program detects the status of your device automatically to minimize deviation.
+                    It keeps the device performing optimally.
+                  </p>
+                </div>
+              </div>
+
+              {/* Right column - Calibration Flow & Start button */}
+              <div className="flex flex-col">
+                <h3 className="text-base font-semibold text-bambu-green mb-4 text-center border-b border-bambu-dark-tertiary pb-2">
+                  Calibration Flow
+                </h3>
+
+                {/* Timeline progress indicator */}
+                <div className="flex-1 py-4 pl-4">
+                  {hasSelection ? (
+                    <div className="space-y-0">
+                      {expectedFlow.map((step, index) => {
+                        const isActive = calibrationStarted && !calibrationCompleted && step.stages.includes(currentStage);
+                        const isComplete = calibrationCompleted || (calibrationStarted && currentStepIndex > index);
+                        return (
+                          <TimelineStep
+                            key={step.name}
+                            step={index + 1}
+                            name={step.name}
+                            isActive={isActive}
+                            isComplete={isComplete}
+                            isLast={index === expectedFlow.length - 1}
+                          />
+                        );
+                      })}
+                      {/* Show current stage name if it's not in expected flow */}
+                      {currentStage >= 0 && currentStepIndex === -1 && status?.stg_cur_name && (
+                        <div className="mt-4 text-xs text-bambu-gray">
+                          Current: {status.stg_cur_name}
+                        </div>
+                      )}
+                    </div>
+                  ) : (
+                    <div className="flex items-center justify-center h-full text-sm text-bambu-gray italic">
+                      Select calibration steps
+                    </div>
+                  )}
+                </div>
+
+                {/* Start/Calibrating/Completed button */}
+                {calibrationCompleted ? (
+                  <div className="space-y-2">
+                    <button
+                      disabled
+                      className="w-full py-2.5 px-4 rounded-lg font-medium text-sm flex items-center justify-center gap-2 bg-bambu-green text-white cursor-default"
+                    >
+                      <Check className="w-4 h-4" />
+                      Completed
+                    </button>
+                    <button
+                      onClick={resetCalibration}
+                      className="w-full py-2 px-4 rounded-lg font-medium text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
+                    >
+                      New Calibration
+                    </button>
+                  </div>
+                ) : (
+                  <button
+                    onClick={() => calibrationMutation.mutate()}
+                    disabled={!canStart}
+                    className={`w-full py-2.5 px-4 rounded-lg font-medium text-sm flex items-center justify-center gap-2 transition-colors ${
+                      isCalibrating
+                        ? 'bg-bambu-gray/50 text-white cursor-not-allowed'
+                        : canStart
+                        ? 'bg-bambu-green hover:bg-bambu-green/90 text-white'
+                        : 'bg-bambu-dark-tertiary text-bambu-gray cursor-not-allowed'
+                    }`}
+                  >
+                    {isCalibrating ? (
+                      <>
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                        Calibrating
+                      </>
+                    ) : calibrationMutation.isPending ? (
+                      <>
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                        Starting...
+                      </>
+                    ) : (
+                      'Start Calibration'
+                    )}
+                  </button>
+                )}
+
+                {calibrationMutation.isError && (
+                  <div className="mt-2 text-xs text-red-400 text-center">
+                    {calibrationMutation.error?.message || 'Failed to start calibration'}
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 10 - 7
frontend/src/components/control/ExtruderControls.tsx

@@ -9,10 +9,11 @@ interface ExtruderControlsProps {
   printerId: number;
   status: PrinterStatus | null | undefined;
   nozzleCount: number;
+  disabled?: boolean;
 }
 
-export function ExtruderControls({ printerId, status, nozzleCount }: ExtruderControlsProps) {
-  const isConnected = status?.connected ?? false;
+export function ExtruderControls({ printerId, status, nozzleCount, disabled = false }: ExtruderControlsProps) {
+  const isConnected = (status?.connected ?? false) && !disabled;
   const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
   const isDualNozzle = nozzleCount > 1;
 
@@ -60,23 +61,25 @@ export function ExtruderControls({ printerId, status, nozzleCount }: ExtruderCon
       <div className="flex flex-col items-center gap-1.5 justify-center">
         {/* Left/Right Toggle - only for dual nozzle */}
         {isDualNozzle && (
-          <div className="flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0">
+          <div className={`flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0 ${isDisabled ? 'opacity-50' : ''}`}>
             <button
               onClick={() => setSelectedNozzle('left')}
-              className={`px-3 py-1.5 text-sm border-r border-bambu-dark-tertiary transition-colors ${
+              disabled={isDisabled}
+              className={`px-3 py-1.5 text-sm border-r border-bambu-dark-tertiary transition-colors disabled:cursor-not-allowed ${
                 selectedNozzle === 'left'
                   ? 'bg-bambu-green text-white'
-                  : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary'
+                  : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary disabled:hover:bg-bambu-dark-secondary'
               }`}
             >
               Left
             </button>
             <button
               onClick={() => setSelectedNozzle('right')}
-              className={`px-3 py-1.5 text-sm transition-colors ${
+              disabled={isDisabled}
+              className={`px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed ${
                 selectedNozzle === 'right'
                   ? 'bg-bambu-green text-white'
-                  : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary'
+                  : 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary disabled:hover:bg-bambu-dark-secondary'
               }`}
             >
               Right

+ 3 - 2
frontend/src/components/control/JogPad.tsx

@@ -7,10 +7,11 @@ import { ConfirmModal } from '../ConfirmModal';
 interface JogPadProps {
   printerId: number;
   status: PrinterStatus | null | undefined;
+  disabled?: boolean;
 }
 
-export function JogPad({ printerId, status }: JogPadProps) {
-  const isConnected = status?.connected ?? false;
+export function JogPad({ printerId, status, disabled = false }: JogPadProps) {
+  const isConnected = (status?.connected ?? false) && !disabled;
 
   const [confirmModal, setConfirmModal] = useState<{
     action: string;

+ 3 - 2
frontend/src/components/control/TemperatureColumn.tsx

@@ -17,14 +17,15 @@ interface TemperatureColumnProps {
   printerId: number;
   status: PrinterStatus | null | undefined;
   nozzleCount: number;
+  disabled?: boolean;
 }
 
 type EditingField = 'nozzle' | 'nozzle_2' | 'bed' | null;
 
-export function TemperatureColumn({ printerId, status, nozzleCount }: TemperatureColumnProps) {
+export function TemperatureColumn({ printerId, status, nozzleCount, disabled = false }: TemperatureColumnProps) {
   const temps = (status?.temperatures ?? {}) as Temperatures;
   const isDualNozzle = nozzleCount > 1;
-  const isConnected = status?.connected ?? false;
+  const isConnected = (status?.connected ?? false) && !disabled;
 
   const [editing, setEditing] = useState<EditingField>(null);
   const [editValue, setEditValue] = useState('');

+ 25 - 1
frontend/src/pages/ControlPage.tsx

@@ -13,6 +13,7 @@ import { AMSSectionDual } from '../components/control/AMSSectionDual';
 import { CameraSettingsModal } from '../components/control/CameraSettingsModal';
 import { PrinterPartsModal } from '../components/control/PrinterPartsModal';
 import { PrintOptionsModal } from '../components/control/PrintOptionsModal';
+import { CalibrationModal } from '../components/control/CalibrationModal';
 import { Loader2, WifiOff, Video, Webcam, Settings } from 'lucide-react';
 
 export function ControlPage() {
@@ -21,6 +22,7 @@ export function ControlPage() {
   const [showCameraSettings, setShowCameraSettings] = useState(false);
   const [showPrinterParts, setShowPrinterParts] = useState(false);
   const [showPrintOptions, setShowPrintOptions] = useState(false);
+  const [showCalibration, setShowCalibration] = useState(false);
 
   // Fetch all printers
   const { data: printers, isLoading: loadingPrinters } = useQuery({
@@ -74,6 +76,12 @@ export function ControlPage() {
   const selectedPrinter = printers?.find((p) => p.id === selectedPrinterId);
   const selectedStatus = selectedPrinterId ? statuses?.[selectedPrinterId] : null;
 
+  // Calibration stages that indicate active calibration
+  const CALIBRATION_STAGES = new Set([1, 3, 13, 25, 39, 40, 47, 48, 50]);
+  const isCalibrating = selectedStatus
+    ? CALIBRATION_STAGES.has(selectedStatus.stg_cur)
+    : false;
+
   if (loadingPrinters) {
     return (
       <div className="flex items-center justify-center h-screen">
@@ -186,7 +194,10 @@ export function ControlPage() {
                 >
                   Print Options
                 </button>
-                <button className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark">
+                <button
+                  onClick={() => setShowCalibration(true)}
+                  className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark"
+                >
                   Calibration
                 </button>
               </div>
@@ -212,6 +223,7 @@ export function ControlPage() {
                   printerId={selectedPrinter.id}
                   status={selectedStatus}
                   nozzleCount={selectedPrinter.nozzle_count}
+                  disabled={isCalibrating}
                 />
 
                 {/* Movement Column */}
@@ -221,10 +233,12 @@ export function ControlPage() {
                     <JogPad
                       printerId={selectedPrinter.id}
                       status={selectedStatus}
+                      disabled={isCalibrating}
                     />
                     <BedControls
                       printerId={selectedPrinter.id}
                       status={selectedStatus}
+                      disabled={isCalibrating}
                     />
                   </div>
 
@@ -233,6 +247,7 @@ export function ControlPage() {
                     printerId={selectedPrinter.id}
                     status={selectedStatus}
                     nozzleCount={selectedPrinter.nozzle_count}
+                    disabled={isCalibrating}
                   />
                 </div>
                 </div>
@@ -275,6 +290,15 @@ export function ControlPage() {
           onClose={() => setShowPrintOptions(false)}
         />
       )}
+
+      {/* Calibration Modal */}
+      {showCalibration && selectedPrinter && (
+        <CalibrationModal
+          printer={selectedPrinter}
+          status={selectedStatus}
+          onClose={() => setShowCalibration(false)}
+        />
+      )}
     </div>
   );
 }

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


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


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

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