Files
maziggy 18938a10ee fix(kprofiles): stop reporting rejected K-profile writes as saved
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.
2026-08-01 10:35:22 +02:00

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"}