mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
@@ -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,4 +1,5 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -95,6 +96,21 @@ async def reset_settings(db: AsyncSession = Depends(get_db)):
|
||||
async def check_ffmpeg():
|
||||
"""Check if ffmpeg is installed and available."""
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
|
||||
# If not found via PATH, check common installation locations
|
||||
# (systemd services often have limited PATH)
|
||||
if ffmpeg_path is None:
|
||||
common_paths = [
|
||||
"/usr/bin/ffmpeg",
|
||||
"/usr/local/bin/ffmpeg",
|
||||
"/opt/homebrew/bin/ffmpeg",
|
||||
"/snap/bin/ffmpeg",
|
||||
]
|
||||
for path in common_paths:
|
||||
if Path(path).exists():
|
||||
ffmpeg_path = path
|
||||
break
|
||||
|
||||
return {
|
||||
"installed": ffmpeg_path is not None,
|
||||
"path": ffmpeg_path,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
Clock,
|
||||
Plus,
|
||||
Trash2,
|
||||
Settings2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RotateCcw,
|
||||
Droplet,
|
||||
Flame,
|
||||
Ruler,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
Square,
|
||||
Cable,
|
||||
Edit3,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { MaintenanceStatus, PrinterMaintenanceOverview } from '../api/client';
|
||||
@@ -334,134 +333,266 @@ function PrinterSection({
|
||||
);
|
||||
}
|
||||
|
||||
// Settings modal for managing custom types
|
||||
function SettingsModal({
|
||||
onClose,
|
||||
// Settings section component - maintenance types and per-printer interval overrides
|
||||
function SettingsSection({
|
||||
overview,
|
||||
types,
|
||||
onUpdateInterval,
|
||||
onAddType,
|
||||
onDeleteType,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
overview: PrinterMaintenanceOverview[] | undefined;
|
||||
types: Array<{ id: number; name: string; default_interval_hours: number; icon: string | null; is_system: boolean }>;
|
||||
onUpdateInterval: (id: number, customInterval: number | null) => void;
|
||||
onAddType: (data: { name: string; description?: string; default_interval_hours: number; icon?: string }) => void;
|
||||
onDeleteType: (id: number) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [interval, setInterval] = useState('100');
|
||||
const [icon, setIcon] = useState('Wrench');
|
||||
const [editingInterval, setEditingInterval] = useState<number | null>(null);
|
||||
const [intervalInput, setIntervalInput] = useState('');
|
||||
const [showAddType, setShowAddType] = useState(false);
|
||||
const [newTypeName, setNewTypeName] = useState('');
|
||||
const [newTypeInterval, setNewTypeInterval] = useState('100');
|
||||
const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSaveInterval = (itemId: number, defaultInterval: number) => {
|
||||
const newInterval = parseFloat(intervalInput);
|
||||
if (!isNaN(newInterval) && newInterval > 0) {
|
||||
const customInterval = Math.abs(newInterval - defaultInterval) < 0.01 ? null : newInterval;
|
||||
onUpdateInterval(itemId, customInterval);
|
||||
}
|
||||
setEditingInterval(null);
|
||||
};
|
||||
|
||||
const handleAddType = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && parseFloat(interval) > 0) {
|
||||
if (newTypeName.trim() && parseFloat(newTypeInterval) > 0) {
|
||||
onAddType({
|
||||
name: name.trim(),
|
||||
default_interval_hours: parseFloat(interval),
|
||||
icon,
|
||||
name: newTypeName.trim(),
|
||||
default_interval_hours: parseFloat(newTypeInterval),
|
||||
icon: newTypeIcon,
|
||||
});
|
||||
setName('');
|
||||
setInterval('100');
|
||||
setNewTypeName('');
|
||||
setNewTypeInterval('100');
|
||||
setShowAddType(false);
|
||||
}
|
||||
};
|
||||
|
||||
const printerItems = overview?.map(p => ({
|
||||
printerId: p.printer_id,
|
||||
printerName: p.printer_name,
|
||||
items: p.maintenance_items.sort((a, b) => a.maintenance_type_id - b.maintenance_type_id),
|
||||
})).sort((a, b) => a.printerName.localeCompare(b.printerName)) || [];
|
||||
|
||||
const systemTypes = types.filter(t => t.is_system);
|
||||
const customTypes = types.filter(t => !t.is_system);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Maintenance Settings</h3>
|
||||
|
||||
{/* Existing custom types */}
|
||||
{customTypes.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm text-bambu-gray mb-2">Custom Maintenance Types</h4>
|
||||
<div className="space-y-2">
|
||||
{customTypes.map((type) => {
|
||||
const Icon = getIcon(type.icon);
|
||||
return (
|
||||
<div key={type.id} className="flex items-center justify-between p-2 bg-bambu-dark rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-bambu-gray" />
|
||||
<span className="text-white text-sm">{type.name}</span>
|
||||
<span className="text-bambu-gray text-xs">({type.default_interval_hours}h)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${type.name}"?`)) {
|
||||
onDeleteType(type.id);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new type */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h4 className="text-sm text-bambu-gray mb-2">Add Custom Type</h4>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
|
||||
placeholder="Name (e.g., Replace HEPA Filter)"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="w-20 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
|
||||
placeholder="Hours"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{Object.keys(iconMap).map((iconName) => {
|
||||
const IconComp = iconMap[iconName];
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
onClick={() => setIcon(iconName)}
|
||||
className={`p-1.5 rounded ${
|
||||
icon === iconName
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<IconComp className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={!name.trim()}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-bambu-dark-tertiary flex justify-end">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
<div className="space-y-8">
|
||||
{/* Maintenance Types */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowAddType(!showAddType)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Custom Type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add custom type form */}
|
||||
{showAddType && (
|
||||
<Card className="mb-4">
|
||||
<div className="p-4">
|
||||
<form onSubmit={handleAddType}>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-bambu-gray mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTypeName}
|
||||
onChange={(e) => setNewTypeName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
|
||||
placeholder="e.g., Replace HEPA Filter"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<label className="block text-xs text-bambu-gray mb-1">Interval (hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newTypeInterval}
|
||||
onChange={(e) => setNewTypeInterval(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Icon</label>
|
||||
<div className="flex gap-1">
|
||||
{Object.keys(iconMap).map((iconName) => {
|
||||
const IconComp = iconMap[iconName];
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
onClick={() => setNewTypeIcon(iconName)}
|
||||
className={`p-2 rounded ${
|
||||
newTypeIcon === iconName
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<IconComp className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={!newTypeName.trim()}>Add</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setShowAddType(false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Types grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{/* System types */}
|
||||
{systemTypes.map((type) => {
|
||||
const Icon = getIcon(type.icon);
|
||||
return (
|
||||
<div key={type.id} className="bg-bambu-dark-secondary rounded-lg p-4 border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-bambu-dark rounded-lg">
|
||||
<Icon className="w-5 h-5 text-bambu-gray" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">{type.name}</div>
|
||||
<div className="text-xs text-bambu-gray mt-0.5">{type.default_interval_hours}h interval</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Custom types */}
|
||||
{customTypes.map((type) => {
|
||||
const Icon = getIcon(type.icon);
|
||||
return (
|
||||
<div key={type.id} className="bg-bambu-dark-secondary rounded-lg p-4 border border-bambu-green/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-bambu-green/20 rounded-lg">
|
||||
<Icon className="w-5 h-5 text-bambu-green" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">{type.name}</span>
|
||||
<span className="px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded">Custom</span>
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray mt-0.5">{type.default_interval_hours}h interval</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${type.name}"?`)) {
|
||||
onDeleteType(type.id);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-printer interval overrides */}
|
||||
{printerItems.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Interval Overrides</h2>
|
||||
<p className="text-sm text-bambu-gray mb-4">
|
||||
Set custom intervals per printer.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{printerItems.map((printer) => (
|
||||
<Card key={printer.printerId}>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-medium text-white mb-3">{printer.printerName}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{printer.items.map((item) => {
|
||||
const Icon = getIcon(item.maintenance_type_icon);
|
||||
const typeInfo = types.find(t => t.id === item.maintenance_type_id);
|
||||
const defaultInterval = typeInfo?.default_interval_hours || item.interval_hours;
|
||||
const isEditing = editingInterval === item.id;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-2 p-2 bg-bambu-dark rounded-lg">
|
||||
<Icon className="w-4 h-4 text-bambu-gray shrink-0" />
|
||||
<span className="text-xs text-bambu-gray flex-1 truncate">{item.maintenance_type_name}</span>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={intervalInput}
|
||||
onChange={(e) => setIntervalInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveInterval(item.id, defaultInterval);
|
||||
if (e.key === 'Escape') setEditingInterval(null);
|
||||
}}
|
||||
className="w-16 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
|
||||
min="1"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleSaveInterval(item.id, defaultInterval)}>OK</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingInterval(item.id);
|
||||
setIntervalInput(item.interval_hours.toString());
|
||||
}}
|
||||
className="px-2 py-1 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green rounded text-xs font-medium text-white transition-colors"
|
||||
>
|
||||
{item.interval_hours}h
|
||||
<Edit3 className="w-3 h-3 inline ml-1.5 text-bambu-gray" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{printerItems.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<Clock className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
|
||||
<p className="text-bambu-gray">No printers configured</p>
|
||||
<p className="text-sm text-bambu-gray/70 mt-1">
|
||||
Add printers to configure maintenance intervals
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TabType = 'status' | 'settings';
|
||||
|
||||
export function MaintenancePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('status');
|
||||
|
||||
const { data: overview, isLoading } = useQuery({
|
||||
queryKey: ['maintenanceOverview'],
|
||||
@@ -561,52 +692,78 @@ export function MaintenancePage() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Maintenance</h1>
|
||||
<p className="text-bambu-gray text-sm">
|
||||
{totalDue > 0 && <span className="text-red-400">{totalDue} tasks overdue</span>}
|
||||
{totalDue > 0 && totalWarning > 0 && ' · '}
|
||||
{totalWarning > 0 && <span className="text-yellow-400">{totalWarning} due soon</span>}
|
||||
{totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setShowSettings(true)}>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Maintenance</h1>
|
||||
<p className="text-bambu-gray text-sm">
|
||||
{activeTab === 'status' ? (
|
||||
<>
|
||||
{totalDue > 0 && <span className="text-red-400">{totalDue} tasks overdue</span>}
|
||||
{totalDue > 0 && totalWarning > 0 && ' · '}
|
||||
{totalWarning > 0 && <span className="text-yellow-400">{totalWarning} due soon</span>}
|
||||
{totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'}
|
||||
</>
|
||||
) : (
|
||||
'Configure maintenance types and intervals'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
|
||||
<button
|
||||
onClick={() => setActiveTab('status')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
activeTab === 'status'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray border-transparent hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Status
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
activeTab === 'settings'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray border-transparent hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Printers - sorted alphabetically */}
|
||||
<div className="space-y-4">
|
||||
{overview && overview.length > 0 ? (
|
||||
[...overview].sort((a, b) => a.printer_name.localeCompare(b.printer_name)).map((printerOverview) => (
|
||||
<PrinterSection
|
||||
key={printerOverview.printer_id}
|
||||
overview={printerOverview}
|
||||
onPerform={handlePerform}
|
||||
onToggle={handleToggle}
|
||||
onSetHours={handleSetHours}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<Wrench className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
|
||||
<p className="text-bambu-gray">No printers configured</p>
|
||||
<p className="text-sm text-bambu-gray/70 mt-1">
|
||||
Add printers to start tracking maintenance
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings modal */}
|
||||
{showSettings && types && (
|
||||
<SettingsModal
|
||||
onClose={() => setShowSettings(false)}
|
||||
types={types}
|
||||
{/* Tab content */}
|
||||
{activeTab === 'status' ? (
|
||||
<div className="space-y-4">
|
||||
{overview && overview.length > 0 ? (
|
||||
[...overview].sort((a, b) => a.printer_name.localeCompare(b.printer_name)).map((printerOverview) => (
|
||||
<PrinterSection
|
||||
key={printerOverview.printer_id}
|
||||
overview={printerOverview}
|
||||
onPerform={handlePerform}
|
||||
onToggle={handleToggle}
|
||||
onSetHours={handleSetHours}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<Wrench className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
|
||||
<p className="text-bambu-gray">No printers configured</p>
|
||||
<p className="text-sm text-bambu-gray/70 mt-1">
|
||||
Add printers to start tracking maintenance
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsSection
|
||||
overview={overview}
|
||||
types={types || []}
|
||||
onUpdateInterval={(id, customInterval) =>
|
||||
updateMutation.mutate({ id, data: { custom_interval_hours: customInterval } })
|
||||
}
|
||||
onAddType={(data) => addTypeMutation.mutate(data)}
|
||||
onDeleteType={(id) => deleteTypeMutation.mutate(id)}
|
||||
/>
|
||||
|
||||
@@ -87,6 +87,7 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
|
||||
interface PrinterMaintenanceInfo {
|
||||
due_count: number;
|
||||
warning_count: number;
|
||||
total_print_hours: number;
|
||||
}
|
||||
|
||||
function PrinterCard({
|
||||
@@ -175,7 +176,15 @@ function PrinterCard({
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{printer.name}</h3>
|
||||
<p className="text-sm text-bambu-gray">{printer.model || 'Unknown Model'}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{printer.model || 'Unknown Model'}
|
||||
{maintenanceInfo && maintenanceInfo.total_print_hours > 0 && (
|
||||
<span className="ml-2 text-bambu-gray">
|
||||
<Clock className="w-3 h-3 inline-block mr-1" />
|
||||
{Math.round(maintenanceInfo.total_print_hours)}h
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -726,6 +735,7 @@ export function PrintersPage() {
|
||||
acc[overview.printer_id] = {
|
||||
due_count: overview.due_count,
|
||||
warning_count: overview.warning_count,
|
||||
total_print_hours: overview.total_print_hours,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
|
||||
@@ -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-soeWpmRO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-4olH2ask.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user