mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Completely refactored k-profile module
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CKBzHHT0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-H_ymON9v.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bi9VwPck.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CJM6Rz8E.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user