mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Saving a K-profile was fire-and-forget. set_kprofiles_batch published and returned True, and the printer's extrusion_cali_set answer was logged at DEBUG and dropped, so a write the printer refused was reported to the user as saved (#2718, reporter @jmoore-skild). The reason it could not simply be gated on: the answer itself was wrong. Single-nozzle firmware returned result:"fail" with reason:"invalid tray_id" on writes that demonstrably applied. Measured against an X1C and an H2D over MQTT, the cause is the tray_id:-1 Bambuddy itself put in the payload. Sending three otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0 succeeds, and cali_idx:-1 is accepted either way, so only that one field is at fault. The H2D ignores the value entirely; the X1C validates it, complains, and applies the write anyway. BambuStudio always sends a real tray_id and defaults it to 0 for a manually entered profile. With tray_id:0 the acknowledgement is honest, and the printer echoes back the sequence_id we sent -- confirmed for extrusion_cali_get, _set and _del on both printer classes -- so it can be matched to the write that caused it. Writes now return their sequence_id and the routes await the verdict, turning a real failure into an error that carries the printer's own reason. A printer that stays silent is still treated as success: no answer is not evidence of refusal, and firmware that never answers must not turn every save into an error. Raises the ack to INFO. It sat at DEBUG, so the one line that explains a failed save was absent from every support bundle -- the same reasoning that put ams_filament_drying at INFO for #1447. Also fixes extrusion_cali_set building its payload from str(self._sequence_id) without incrementing first, reusing the previous command's id. Harmless while nothing correlated on it, fatal now that the write path does. Adds supports_nozzle_flow_type() for the Standard / High Flow choice, which the K-Profiles UI previously showed as "Not reported by printer" -- not a value anyone can save. Most printers omit the nozzle identity from their calibration table entirely, and the slicer treats that as Standard rather than unknown; Bambuddy now does the same and keeps the choice editable. The field is hidden only where the model ships a single nozzle variant, using the slicer's own rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine preset) evaluated across every bundled Bambu profile. That puts only A1, A1 Mini and A2L on the hidden side -- it is not the single- versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S are all single-nozzle and all carry two variants. Editing a profile also no longer writes back an empty nozzle_id. Wiki records that on printers which omit the field the chosen flow type is discarded by the firmware and reads back as Standard, in Bambu Studio as well, so it does not get filed as a bug again.
419 lines
14 KiB
Python
419 lines
14 KiB
Python
"""API routes for K-profile (pressure advance) management."""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.models.user import User
|
|
from backend.app.schemas.kprofile import (
|
|
KProfile,
|
|
KProfileCreate,
|
|
KProfileDelete,
|
|
KProfileNote,
|
|
KProfileNoteResponse,
|
|
KProfilesResponse,
|
|
)
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/printers/{printer_id}/kprofiles", tags=["kprofiles"])
|
|
|
|
|
|
@router.get("/", response_model=KProfilesResponse)
|
|
async def get_kprofiles(
|
|
printer_id: int,
|
|
nozzle_diameter: str = "0.4",
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
|
|
):
|
|
"""Get K-profiles from a printer.
|
|
|
|
Args:
|
|
printer_id: ID of the printer
|
|
nozzle_diameter: Filter by nozzle diameter (default: "0.4")
|
|
"""
|
|
# Check printer exists
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
# Get MQTT client for printer
|
|
client = printer_manager.get_client(printer_id)
|
|
if not client or not client.state.connected:
|
|
raise HTTPException(400, "Printer not connected")
|
|
|
|
# Request K-profiles from printer
|
|
profiles = await client.get_kprofiles(nozzle_diameter=nozzle_diameter)
|
|
|
|
# Convert from MQTT dataclass to Pydantic schema
|
|
return KProfilesResponse(
|
|
profiles=[
|
|
KProfile(
|
|
slot_id=p.slot_id,
|
|
extruder_id=p.extruder_id,
|
|
nozzle_id=p.nozzle_id,
|
|
nozzle_diameter=p.nozzle_diameter,
|
|
filament_id=p.filament_id,
|
|
name=p.name,
|
|
k_value=p.k_value,
|
|
n_coef=p.n_coef,
|
|
ams_id=p.ams_id,
|
|
tray_id=p.tray_id,
|
|
setting_id=p.setting_id,
|
|
)
|
|
for p in profiles
|
|
],
|
|
nozzle_diameter=nozzle_diameter,
|
|
)
|
|
|
|
|
|
@router.post("/", response_model=dict)
|
|
async def set_kprofile(
|
|
printer_id: int,
|
|
profile: KProfileCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
|
):
|
|
"""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()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
# Get MQTT client for printer
|
|
client = printer_manager.get_client(printer_id)
|
|
if not client or not client.state.connected:
|
|
raise HTTPException(400, "Printer not connected")
|
|
|
|
# Detect dual-nozzle for the in-place edit format. Runtime detection from
|
|
# device.extruder.info beats serial-prefix heuristics — H2S shares prefix
|
|
# "094" with H2D but is single-nozzle (#1386). Model name is the fallback
|
|
# for the brief window after connect before push data arrives.
|
|
from backend.app.utils.printer_models import is_dual_nozzle_model
|
|
|
|
is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
|
|
|
|
if is_edit and is_dual_nozzle:
|
|
# Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
|
|
logger.info("[API] Dual-nozzle in-place edit: cali_idx=%s", 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:
|
|
# Single-nozzle edit: use delete + add approach
|
|
logger.info("[API] Edit: deleting existing profile slot_id=%s", 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")
|
|
ok, detail = await client.await_cali_ack(delete_success)
|
|
if not ok:
|
|
raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
|
|
|
|
# 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")
|
|
|
|
# The printer answers extrusion_cali_set with result/reason, echoing our
|
|
# sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
|
|
# so a rejected write was reported to the user as saved.
|
|
ok, detail = await client.await_cali_ack(success)
|
|
if not ok:
|
|
raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
|
|
|
|
message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
|
|
return {"success": True, "message": message}
|
|
|
|
|
|
@router.post("/batch", response_model=dict)
|
|
async def set_kprofiles_batch(
|
|
printer_id: int,
|
|
profiles: list[KProfileCreate],
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
|
):
|
|
"""Create multiple K-profiles in a single command (for dual-nozzle).
|
|
|
|
This sends all profiles in one MQTT command, which is more reliable
|
|
for dual-nozzle printers that may not handle sequential commands well.
|
|
|
|
Args:
|
|
printer_id: ID of the printer
|
|
profiles: List of K-profiles to set
|
|
"""
|
|
if not profiles:
|
|
raise HTTPException(400, "No profiles provided")
|
|
|
|
logger.info("[API] set_kprofiles_batch: printer=%s, %s profiles", printer_id, len(profiles))
|
|
for p in profiles:
|
|
logger.info(" - extruder_id=%s, name=%s, k_value=%s", p.extruder_id, p.name, p.k_value)
|
|
|
|
# Check printer exists
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
# Get MQTT client for printer
|
|
client = printer_manager.get_client(printer_id)
|
|
if not client or not client.state.connected:
|
|
raise HTTPException(400, "Printer not connected")
|
|
|
|
# Build list of profile dicts for batch command
|
|
profile_dicts = [
|
|
{
|
|
"filament_id": p.filament_id,
|
|
"name": p.name,
|
|
"k_value": p.k_value,
|
|
"nozzle_id": p.nozzle_id,
|
|
"extruder_id": p.extruder_id,
|
|
"setting_id": p.setting_id,
|
|
"slot_id": p.slot_id,
|
|
}
|
|
for p in profiles
|
|
]
|
|
|
|
# Get nozzle_diameter from first profile (all should have same)
|
|
nozzle_diameter = profiles[0].nozzle_diameter
|
|
|
|
success = client.set_kprofiles_batch(profile_dicts, nozzle_diameter)
|
|
|
|
if not success:
|
|
raise HTTPException(500, "Failed to send K-profiles batch command")
|
|
|
|
ok, detail = await client.await_cali_ack(success)
|
|
if not ok:
|
|
raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
|
|
|
|
return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
|
|
|
|
|
|
@router.delete("/", response_model=dict)
|
|
async def delete_kprofile(
|
|
printer_id: int,
|
|
profile: KProfileDelete,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
|
|
):
|
|
"""Delete a K-profile from the printer.
|
|
|
|
Args:
|
|
printer_id: ID of the printer
|
|
profile: K-profile identification data for deletion
|
|
"""
|
|
# Check printer exists
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
# Get MQTT client for printer
|
|
client = printer_manager.get_client(printer_id)
|
|
if not client or not client.state.connected:
|
|
raise HTTPException(400, "Printer not connected")
|
|
|
|
# Send the delete command to printer
|
|
logger.info(
|
|
f"[API] delete_kprofile: printer={printer_id}, slot_id={profile.slot_id}, "
|
|
f"setting_id={profile.setting_id}, filament_id={profile.filament_id}"
|
|
)
|
|
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 success:
|
|
raise HTTPException(500, "Failed to send K-profile delete command")
|
|
|
|
ok, detail = await client.await_cali_ack(success)
|
|
if not ok:
|
|
raise HTTPException(500, f"Printer rejected the delete: {detail}")
|
|
|
|
# Wait for printer to process the delete before frontend refetches
|
|
await asyncio.sleep(0.5)
|
|
|
|
return {"success": True, "message": "K-profile deleted successfully"}
|
|
|
|
|
|
@router.get("/notes", response_model=KProfileNoteResponse)
|
|
async def get_kprofile_notes(
|
|
printer_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
|
|
):
|
|
"""Get all K-profile notes for a printer.
|
|
|
|
Notes are stored locally since printers don't support notes.
|
|
|
|
Args:
|
|
printer_id: ID of the printer
|
|
"""
|
|
# Check printer exists
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
# Get all notes for this printer
|
|
result = await db.execute(select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id))
|
|
notes = result.scalars().all()
|
|
|
|
# Return as a dictionary mapping setting_id -> note
|
|
return KProfileNoteResponse(notes={note.setting_id: note.note for note in notes})
|
|
|
|
|
|
@router.put("/notes", response_model=dict)
|
|
async def set_kprofile_note(
|
|
printer_id: int,
|
|
note_data: KProfileNote,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
|
):
|
|
"""Set or update a note for a K-profile.
|
|
|
|
Args:
|
|
printer_id: ID of the printer
|
|
note_data: The note data (setting_id and note content)
|
|
"""
|
|
# Check printer exists
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
# Find existing note or create new one
|
|
result = await db.execute(
|
|
select(KProfileNoteModel).where(
|
|
KProfileNoteModel.printer_id == printer_id,
|
|
KProfileNoteModel.setting_id == note_data.setting_id,
|
|
)
|
|
)
|
|
existing_note = result.scalar_one_or_none()
|
|
|
|
if note_data.note.strip():
|
|
# Save or update note
|
|
if existing_note:
|
|
existing_note.note = note_data.note
|
|
else:
|
|
new_note = KProfileNoteModel(
|
|
printer_id=printer_id,
|
|
setting_id=note_data.setting_id,
|
|
note=note_data.note,
|
|
)
|
|
db.add(new_note)
|
|
await db.commit()
|
|
return {"success": True, "message": "Note saved"}
|
|
else:
|
|
# Delete note if empty
|
|
if existing_note:
|
|
await db.delete(existing_note)
|
|
await db.commit()
|
|
return {"success": True, "message": "Note deleted"}
|
|
|
|
|
|
@router.delete("/notes/{setting_id}", response_model=dict)
|
|
async def delete_kprofile_note(
|
|
printer_id: int,
|
|
setting_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
|
|
):
|
|
"""Delete a note for a K-profile.
|
|
|
|
Args:
|
|
printer_id: ID of the printer
|
|
setting_id: The setting_id of the K-profile
|
|
"""
|
|
# Check printer exists
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
# Find and delete the note
|
|
result = await db.execute(
|
|
select(KProfileNoteModel).where(
|
|
KProfileNoteModel.printer_id == printer_id,
|
|
KProfileNoteModel.setting_id == setting_id,
|
|
)
|
|
)
|
|
existing_note = result.scalar_one_or_none()
|
|
|
|
if existing_note:
|
|
await db.delete(existing_note)
|
|
await db.commit()
|
|
|
|
return {"success": True, "message": "Note deleted"}
|