Completely refactored k-profile module

This commit is contained in:
Martin Ziegler
2025-12-01 15:17:49 +01:00
parent ae5954fd09
commit 6de875582a
10 changed files with 622 additions and 205 deletions
+73 -12
View File
@@ -1,5 +1,6 @@
"""API routes for K-profile (pressure advance) management."""
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException
@@ -77,10 +78,22 @@ async def set_kprofile(
):
"""Create or update a K-profile on the printer.
For H2D edits (slot_id > 0), this performs an in-place edit using cali_idx.
For other printers or new profiles, this adds a new profile.
Args:
printer_id: ID of the printer
profile: K-profile data to set
"""
is_edit = profile.slot_id > 0
operation = "edit" if is_edit else "add"
logger.info(
f"[API] set_kprofile ({operation}): printer={printer_id}, slot_id={profile.slot_id}, "
f"extruder_id={profile.extruder_id}, nozzle_id={profile.nozzle_id}, "
f"name={profile.name}, filament_id={profile.filament_id}, k_value={profile.k_value}"
)
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -92,22 +105,69 @@ async def set_kprofile(
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
# Send the K-profile to printer
success = client.set_kprofile(
filament_id=profile.filament_id,
name=profile.name,
k_value=profile.k_value,
nozzle_diameter=profile.nozzle_diameter,
nozzle_id=profile.nozzle_id,
extruder_id=profile.extruder_id,
setting_id=profile.setting_id,
slot_id=profile.slot_id,
)
# Detect H2D by serial number prefix
is_h2d = printer.serial_number.startswith("094")
if is_edit and is_h2d:
# H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id
logger.info(f"[API] H2D in-place edit: cali_idx={profile.slot_id}")
success = client.set_kprofile(
filament_id=profile.filament_id,
name=profile.name,
k_value=profile.k_value,
nozzle_diameter=profile.nozzle_diameter,
nozzle_id=profile.nozzle_id,
extruder_id=profile.extruder_id,
setting_id=None,
slot_id=0,
cali_idx=profile.slot_id, # Pass the original slot for in-place edit
)
elif is_edit:
# Non-H2D edit: use delete + add approach
logger.info(f"[API] Edit: deleting existing profile slot_id={profile.slot_id}")
delete_success = client.delete_kprofile(
cali_idx=profile.slot_id,
filament_id=profile.filament_id,
nozzle_id=profile.nozzle_id,
nozzle_diameter=profile.nozzle_diameter,
extruder_id=profile.extruder_id,
setting_id=profile.setting_id,
)
if not delete_success:
raise HTTPException(500, "Failed to delete existing K-profile for edit")
# Wait for printer to process the delete before adding
await asyncio.sleep(0.5)
logger.info("[API] Edit: delete complete, now adding updated profile")
success = client.set_kprofile(
filament_id=profile.filament_id,
name=profile.name,
k_value=profile.k_value,
nozzle_diameter=profile.nozzle_diameter,
nozzle_id=profile.nozzle_id,
extruder_id=profile.extruder_id,
setting_id=None, # Generate new setting_id for add
slot_id=0, # Always 0 for add (new profile)
)
else:
# New profile: add with slot_id=0
success = client.set_kprofile(
filament_id=profile.filament_id,
name=profile.name,
k_value=profile.k_value,
nozzle_diameter=profile.nozzle_diameter,
nozzle_id=profile.nozzle_id,
extruder_id=profile.extruder_id,
setting_id=None, # Generate new setting_id for add
slot_id=0, # Always 0 for add (new profile)
)
if not success:
raise HTTPException(500, "Failed to send K-profile command")
return {"success": True, "message": "K-profile set successfully"}
message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
return {"success": True, "message": message}
@router.delete("/", response_model=dict)
@@ -140,6 +200,7 @@ async def delete_kprofile(
nozzle_id=profile.nozzle_id,
nozzle_diameter=profile.nozzle_diameter,
extruder_id=profile.extruder_id,
setting_id=profile.setting_id,
)
if not success:
+1
View File
@@ -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)
+179 -70
View File
@@ -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
+1
View File
@@ -351,6 +351,7 @@ export interface KProfileDelete {
nozzle_id: string; // e.g., "HH00-0.4"
nozzle_diameter: string; // e.g., "0.4"
filament_id: string; // Bambu filament identifier
setting_id?: string | null; // Setting ID (for X1C series)
}
export interface KProfilesResponse {
+161 -38
View File
@@ -128,7 +128,13 @@ function KProfileModal({
const [modalDiameter, setModalDiameter] = useState(
profile?.nozzle_diameter || nozzleDiameter
);
const [extruderId, setExtruderId] = useState(profile?.extruder_id || 0);
// For new profiles on dual-nozzle: allow selecting multiple extruders
// For editing: use single extruder from the profile
const [selectedExtruders, setSelectedExtruders] = useState<number[]>(
profile ? [profile.extruder_id] : isDualNozzle ? [0, 1] : [0] // Default: both extruders for new dual-nozzle profiles
);
const [isSyncing, setIsSyncing] = useState(false);
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
// Extract unique filaments from existing K-profiles on the printer
// These have valid filament_ids that the printer recognizes
@@ -156,12 +162,20 @@ function KProfileModal({
onSuccess: (result) => {
console.log('[KProfile] Save success:', result);
showToast('K-profile saved');
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
onSave();
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add delay before refreshing to give printer time to process the save
// Bambu printers can be slow to apply K-profile changes
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
onSave();
}, 2500);
},
onError: (error: Error) => {
console.error('[KProfile] Save error:', error);
showToast(error.message, 'error');
setIsSyncing(false);
},
});
@@ -173,12 +187,20 @@ function KProfileModal({
onSuccess: (result) => {
console.log('[KProfile] Delete success:', result);
showToast('K-profile deleted');
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
onClose();
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add delay before refreshing to give printer time to process the delete
// Bambu printers can be slow to apply K-profile changes
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
onClose();
}, 2500);
},
onError: (error: Error) => {
console.error('[KProfile] Delete error:', error);
showToast(error.message, 'error');
setIsSyncing(false);
},
});
@@ -192,35 +214,101 @@ function KProfileModal({
nozzle_id: profile.nozzle_id,
nozzle_diameter: profile.nozzle_diameter,
filament_id: profile.filament_id,
setting_id: profile.setting_id,
});
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate at least one extruder is selected for dual-nozzle
if (isDualNozzle && !profile && selectedExtruders.length === 0) {
showToast('Please select at least one extruder', 'error');
return;
}
// Format k_value to 6 decimal places for Bambu protocol
const formattedKValue = parseFloat(kValue).toFixed(6);
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
const nozzleId = `${nozzleType}-${modalDiameter}`;
// Use the name from the form - it's auto-populated when filament is selected
// but can be edited by the user
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: profile?.setting_id,
slot_id: profile?.slot_id ?? 0,
};
console.log('[KProfile] Saving profile:', payload);
saveMutation.mutate(payload);
// For editing or single extruder: just save one profile
if (profile || selectedExtruders.length === 1) {
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
setting_id: profile?.setting_id,
slot_id: profile?.slot_id ?? 0,
};
console.log('[KProfile] Saving profile:', payload);
saveMutation.mutate(payload);
return;
}
// For new profiles with multiple extruders: save sequentially
setIsSyncing(true);
setSavingProgress({ current: 0, total: selectedExtruders.length });
for (let i = 0; i < selectedExtruders.length; i++) {
const extruderId = selectedExtruders[i];
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: undefined,
slot_id: 0,
};
setSavingProgress({ current: i + 1, total: selectedExtruders.length });
console.log(`[KProfile] Saving profile ${i + 1}/${selectedExtruders.length} for extruder ${extruderId}:`, payload);
try {
await api.setKProfile(printerId, payload);
// Wait between saves to let printer process
if (i < selectedExtruders.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1500));
}
} catch (error) {
console.error(`[KProfile] Failed to save for extruder ${extruderId}:`, error);
showToast(`Failed to save for ${extruderId === 1 ? 'Left' : 'Right'} extruder`, 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
}
}
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
// Wait for final sync before closing
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
onSave();
}, 2500);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<Card className="w-full max-w-md relative">
{/* Syncing overlay */}
{isSyncing && (
<div className="absolute inset-0 bg-bambu-dark-secondary/90 flex flex-col items-center justify-center z-10 rounded-lg">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
<p className="text-white font-medium">
{savingProgress.total > 1
? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...`
: 'Syncing with printer...'}
</p>
<p className="text-bambu-gray text-sm mt-1">Please wait</p>
</div>
)}
<CardContent className="p-0">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-xl font-semibold text-white">
@@ -229,6 +317,7 @@ function KProfileModal({
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
disabled={isSyncing}
>
<X className="w-5 h-5" />
</button>
@@ -245,7 +334,7 @@ function KProfileModal({
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
placeholder="My PLA Profile"
required
required={!profile}
/>
</div>
@@ -299,13 +388,13 @@ function KProfileModal({
}}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
required
required={!profile}
>
<option value="">Select filament...</option>
{/* Show current filament when editing */}
{/* Show current filament when editing - look up from knownFilaments */}
{profile?.filament_id && (
<option key={profile.filament_id} value={profile.filament_id}>
{extractFilamentName(profile.name || profile.filament_id)}
{knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
</option>
)}
{/* Show known filaments from existing K-profiles (for new profiles) */}
@@ -364,19 +453,52 @@ function KProfileModal({
</div>
</div>
{/* Extruder - only show for dual-nozzle printers, read-only when editing */}
{/* Extruder - only show for dual-nozzle printers */}
{isDualNozzle && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Extruder</label>
<select
value={extruderId}
onChange={(e) => setExtruderId(parseInt(e.target.value))}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value={1}>Left</option>
<option value={0}>Right</option>
</select>
<label className="block text-sm text-bambu-gray mb-1">
{profile ? 'Extruder' : 'Extruders'}
</label>
{profile ? (
// Read-only display for editing
<div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
{profile.extruder_id === 1 ? 'Left' : 'Right'}
</div>
) : (
// Checkboxes for new profile - can select both
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedExtruders.includes(1)}
onChange={(e) => {
if (e.target.checked) {
setSelectedExtruders([...selectedExtruders, 1]);
} else {
setSelectedExtruders(selectedExtruders.filter(id => id !== 1));
}
}}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
/>
<span className="text-white">Left</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedExtruders.includes(0)}
onChange={(e) => {
if (e.target.checked) {
setSelectedExtruders([...selectedExtruders, 0]);
} else {
setSelectedExtruders(selectedExtruders.filter(id => id !== 0));
}
}}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
/>
<span className="text-white">Right</span>
</label>
</div>
)}
</div>
)}
@@ -386,7 +508,7 @@ function KProfileModal({
type="button"
variant="secondary"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending}
disabled={deleteMutation.isPending || isSyncing}
className="text-red-500 hover:bg-red-500/10"
>
{deleteMutation.isPending ? (
@@ -400,13 +522,14 @@ function KProfileModal({
type="button"
variant="secondary"
onClick={onClose}
disabled={isSyncing}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={saveMutation.isPending}
disabled={saveMutation.isPending || isSyncing}
className="flex-1"
>
{saveMutation.isPending ? (
+122
View File
@@ -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
View File
@@ -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>