From 6de875582a303bfaab979802930fda36ad1cb393 Mon Sep 17 00:00:00 2001 From: Martin Ziegler Date: Mon, 1 Dec 2025 15:17:49 +0100 Subject: [PATCH] Completely refactored k-profile module --- backend/app/api/routes/kprofiles.py | 85 +++++- backend/app/schemas/kprofile.py | 1 + backend/app/services/bambu_mqtt.py | 249 +++++++++++++----- frontend/src/api/client.ts | 1 + frontend/src/components/KProfilesView.tsx | 199 +++++++++++--- scripts/mqtt_sniffer.py | 122 +++++++++ .../{index-CKBzHHT0.js => index-Bi9VwPck.js} | 164 ++++++------ static/assets/index-CJM6Rz8E.css | 1 + static/assets/index-H_ymON9v.css | 1 - static/index.html | 4 +- 10 files changed, 622 insertions(+), 205 deletions(-) create mode 100644 scripts/mqtt_sniffer.py rename static/assets/{index-CKBzHHT0.js => index-Bi9VwPck.js} (83%) create mode 100644 static/assets/index-CJM6Rz8E.css delete mode 100644 static/assets/index-H_ymON9v.css diff --git a/backend/app/api/routes/kprofiles.py b/backend/app/api/routes/kprofiles.py index a1449cf1d..e535ef311 100644 --- a/backend/app/api/routes/kprofiles.py +++ b/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: diff --git a/backend/app/schemas/kprofile.py b/backend/app/schemas/kprofile.py index 9aab67b9d..04d95ea06 100644 --- a/backend/app/schemas/kprofile.py +++ b/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) diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index a14013b15..02f1fbc3a 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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 + 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), + # 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 + # 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") - # 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)}" + # 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 - 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], + 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, - "sequence_id": str(self._sequence_id), + "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, + "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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f5adb7b0a..e83f0e8ac 100644 --- a/frontend/src/api/client.ts +++ b/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 { diff --git a/frontend/src/components/KProfilesView.tsx b/frontend/src/components/KProfilesView.tsx index 65857c742..8038a4668 100644 --- a/frontend/src/components/KProfilesView.tsx +++ b/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( + 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 (
- + + {/* Syncing overlay */} + {isSyncing && ( +
+ +

+ {savingProgress.total > 1 + ? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...` + : 'Syncing with printer...'} +

+

Please wait

+
+ )}

@@ -229,6 +317,7 @@ function KProfileModal({ @@ -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} />

@@ -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} > - {/* Show current filament when editing */} + {/* Show current filament when editing - look up from knownFilaments */} {profile?.filament_id && ( )} {/* Show known filaments from existing K-profiles (for new profiles) */} @@ -364,19 +453,52 @@ function KProfileModal({
- {/* Extruder - only show for dual-nozzle printers, read-only when editing */} + {/* Extruder - only show for dual-nozzle printers */} {isDualNozzle && (
- - + + {profile ? ( + // Read-only display for editing +
+ {profile.extruder_id === 1 ? 'Left' : 'Right'} +
+ ) : ( + // Checkboxes for new profile - can select both +
+ + +
+ )}
)} @@ -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