kprofiles.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. """API routes for K-profile (pressure advance) management."""
  2. import asyncio
  3. import logging
  4. from fastapi import APIRouter, Depends, HTTPException
  5. from sqlalchemy import select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
  8. from backend.app.core.database import get_db
  9. from backend.app.core.permissions import Permission
  10. from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
  11. from backend.app.models.printer import Printer
  12. from backend.app.models.user import User
  13. from backend.app.schemas.kprofile import (
  14. KProfile,
  15. KProfileCreate,
  16. KProfileDelete,
  17. KProfileNote,
  18. KProfileNoteResponse,
  19. KProfilesResponse,
  20. )
  21. from backend.app.services.bambu_mqtt import CALI_ACK_UNCONFIRMED, KProfileReadError
  22. from backend.app.services.printer_manager import printer_manager
  23. logger = logging.getLogger(__name__)
  24. router = APIRouter(prefix="/printers/{printer_id}/kprofiles", tags=["kprofiles"])
  25. @router.get("/", response_model=KProfilesResponse)
  26. async def get_kprofiles(
  27. printer_id: int,
  28. nozzle_diameter: str = "0.4",
  29. db: AsyncSession = Depends(get_db, scope="function"),
  30. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
  31. ):
  32. """Get K-profiles from a printer.
  33. Args:
  34. printer_id: ID of the printer
  35. nozzle_diameter: Filter by nozzle diameter (default: "0.4")
  36. """
  37. # Check printer exists
  38. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  39. printer = result.scalar_one_or_none()
  40. if not printer:
  41. raise HTTPException(404, "Printer not found")
  42. # Get MQTT client for printer
  43. client = printer_manager.get_client(printer_id)
  44. if not client or not client.state.connected:
  45. raise HTTPException(400, "Printer not connected")
  46. # Request K-profiles from printer. No answer is an error, not an empty
  47. # table: that one means the printer has none for this nozzle (#3404).
  48. try:
  49. profiles = await client.get_kprofiles(nozzle_diameter=nozzle_diameter)
  50. except KProfileReadError as e:
  51. raise HTTPException(504, "The printer did not answer the K-profile request") from e
  52. # Convert from MQTT dataclass to Pydantic schema
  53. return KProfilesResponse(
  54. profiles=[
  55. KProfile(
  56. slot_id=p.slot_id,
  57. extruder_id=p.extruder_id,
  58. nozzle_id=p.nozzle_id,
  59. nozzle_diameter=p.nozzle_diameter,
  60. filament_id=p.filament_id,
  61. name=p.name,
  62. k_value=p.k_value,
  63. n_coef=p.n_coef,
  64. ams_id=p.ams_id,
  65. tray_id=p.tray_id,
  66. setting_id=p.setting_id,
  67. )
  68. for p in profiles
  69. ],
  70. nozzle_diameter=nozzle_diameter,
  71. )
  72. @router.post("/", response_model=dict)
  73. async def set_kprofile(
  74. printer_id: int,
  75. profile: KProfileCreate,
  76. db: AsyncSession = Depends(get_db, scope="function"),
  77. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
  78. ):
  79. """Create or update a K-profile on the printer.
  80. For H2D edits (slot_id > 0), this performs an in-place edit using cali_idx.
  81. For other printers or new profiles, this adds a new profile.
  82. Args:
  83. printer_id: ID of the printer
  84. profile: K-profile data to set
  85. """
  86. is_edit = profile.slot_id > 0
  87. operation = "edit" if is_edit else "add"
  88. logger.info(
  89. f"[API] set_kprofile ({operation}): printer={printer_id}, slot_id={profile.slot_id}, "
  90. f"extruder_id={profile.extruder_id}, nozzle_id={profile.nozzle_id}, "
  91. f"name={profile.name}, filament_id={profile.filament_id}, k_value={profile.k_value}"
  92. )
  93. # Check printer exists
  94. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  95. printer = result.scalar_one_or_none()
  96. if not printer:
  97. raise HTTPException(404, "Printer not found")
  98. # Get MQTT client for printer
  99. client = printer_manager.get_client(printer_id)
  100. if not client or not client.state.connected:
  101. raise HTTPException(400, "Printer not connected")
  102. # Detect dual-nozzle for the in-place edit format. Runtime detection from
  103. # device.extruder.info beats serial-prefix heuristics — H2S shares prefix
  104. # "094" with H2D but is single-nozzle (#1386). Model name is the fallback
  105. # for the brief window after connect before push data arrives.
  106. from backend.app.utils.printer_models import is_dual_nozzle_model
  107. is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
  108. # Whether the printer answered the delete of a delete-and-add edit.
  109. delete_confirmed = True
  110. if is_edit and is_dual_nozzle:
  111. # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
  112. logger.info("[API] Dual-nozzle in-place edit: cali_idx=%s", profile.slot_id)
  113. success = client.set_kprofile(
  114. filament_id=profile.filament_id,
  115. name=profile.name,
  116. k_value=profile.k_value,
  117. nozzle_diameter=profile.nozzle_diameter,
  118. nozzle_id=profile.nozzle_id,
  119. extruder_id=profile.extruder_id,
  120. setting_id=None,
  121. slot_id=0,
  122. cali_idx=profile.slot_id, # Pass the original slot for in-place edit
  123. )
  124. elif is_edit:
  125. # Single-nozzle edit: use delete + add approach
  126. logger.info("[API] Edit: deleting existing profile slot_id=%s", profile.slot_id)
  127. delete_success = client.delete_kprofile(
  128. cali_idx=profile.slot_id,
  129. filament_id=profile.filament_id,
  130. nozzle_id=profile.nozzle_id,
  131. nozzle_diameter=profile.nozzle_diameter,
  132. extruder_id=profile.extruder_id,
  133. setting_id=profile.setting_id,
  134. )
  135. if not delete_success:
  136. raise HTTPException(500, "Failed to delete existing K-profile for edit")
  137. ok, detail = await client.await_cali_ack(delete_success)
  138. if not ok:
  139. raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
  140. delete_confirmed = detail != CALI_ACK_UNCONFIRMED
  141. # Wait for printer to process the delete before adding
  142. await asyncio.sleep(0.5)
  143. logger.info("[API] Edit: delete complete, now adding updated profile")
  144. success = client.set_kprofile(
  145. filament_id=profile.filament_id,
  146. name=profile.name,
  147. k_value=profile.k_value,
  148. nozzle_diameter=profile.nozzle_diameter,
  149. nozzle_id=profile.nozzle_id,
  150. extruder_id=profile.extruder_id,
  151. setting_id=None, # Generate new setting_id for add
  152. slot_id=0, # Always 0 for add (new profile)
  153. )
  154. else:
  155. # New profile: add with slot_id=0
  156. success = client.set_kprofile(
  157. filament_id=profile.filament_id,
  158. name=profile.name,
  159. k_value=profile.k_value,
  160. nozzle_diameter=profile.nozzle_diameter,
  161. nozzle_id=profile.nozzle_id,
  162. extruder_id=profile.extruder_id,
  163. setting_id=None, # Generate new setting_id for add
  164. slot_id=0, # Always 0 for add (new profile)
  165. )
  166. if not success:
  167. raise HTTPException(500, "Failed to send K-profile command")
  168. # The printer answers extrusion_cali_set with result/reason, echoing our
  169. # sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
  170. # so a rejected write was reported to the user as saved.
  171. ok, detail = await client.await_cali_ack(success)
  172. if not ok:
  173. raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
  174. confirmed = detail != CALI_ACK_UNCONFIRMED
  175. if is_edit and not is_dual_nozzle:
  176. # The edit deleted the profile and added it again: read the table back
  177. # to see the edited one landed. A read the printer did not answer
  178. # leaves it to the acks: the write has gone out by then. Same pause as
  179. # before the add.
  180. await asyncio.sleep(0.5)
  181. try:
  182. table = await client.get_kprofiles(nozzle_diameter=profile.nozzle_diameter)
  183. except KProfileReadError as e:
  184. logger.warning("[API] Edit: could not read the K-profile table back: %s", e)
  185. table = None
  186. if table is not None:
  187. if not any(_is_saved_profile(stored, profile) for stored in table):
  188. raise HTTPException(
  189. 502,
  190. "The edited K-profile is not in the printer's table. Check the list before adding it again.",
  191. )
  192. # The old one may still be there when its delete went unanswered.
  193. confirmed = delete_confirmed
  194. if not confirmed:
  195. message = "K-profile sent; the printer did not confirm it"
  196. else:
  197. message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
  198. return {"success": True, "confirmed": confirmed, "message": message}
  199. def _is_saved_profile(stored, profile: KProfileCreate) -> bool:
  200. """Whether a profile read from the printer is the one just written."""
  201. try:
  202. same_k = abs(float(stored.k_value) - float(profile.k_value)) < 1e-6
  203. except (TypeError, ValueError):
  204. return False
  205. return (
  206. same_k
  207. and stored.name == profile.name
  208. and stored.filament_id == profile.filament_id
  209. and stored.extruder_id == profile.extruder_id
  210. )
  211. @router.post("/batch", response_model=dict)
  212. async def set_kprofiles_batch(
  213. printer_id: int,
  214. profiles: list[KProfileCreate],
  215. db: AsyncSession = Depends(get_db, scope="function"),
  216. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
  217. ):
  218. """Create multiple K-profiles in a single command (for dual-nozzle).
  219. This sends all profiles in one MQTT command, which is more reliable
  220. for dual-nozzle printers that may not handle sequential commands well.
  221. Args:
  222. printer_id: ID of the printer
  223. profiles: List of K-profiles to set
  224. """
  225. if not profiles:
  226. raise HTTPException(400, "No profiles provided")
  227. logger.info("[API] set_kprofiles_batch: printer=%s, %s profiles", printer_id, len(profiles))
  228. for p in profiles:
  229. logger.info(" - extruder_id=%s, name=%s, k_value=%s", p.extruder_id, p.name, p.k_value)
  230. # Check printer exists
  231. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  232. printer = result.scalar_one_or_none()
  233. if not printer:
  234. raise HTTPException(404, "Printer not found")
  235. # Get MQTT client for printer
  236. client = printer_manager.get_client(printer_id)
  237. if not client or not client.state.connected:
  238. raise HTTPException(400, "Printer not connected")
  239. # Build list of profile dicts for batch command
  240. profile_dicts = [
  241. {
  242. "filament_id": p.filament_id,
  243. "name": p.name,
  244. "k_value": p.k_value,
  245. "nozzle_id": p.nozzle_id,
  246. "extruder_id": p.extruder_id,
  247. "setting_id": p.setting_id,
  248. "slot_id": p.slot_id,
  249. }
  250. for p in profiles
  251. ]
  252. # Get nozzle_diameter from first profile (all should have same)
  253. nozzle_diameter = profiles[0].nozzle_diameter
  254. success = client.set_kprofiles_batch(profile_dicts, nozzle_diameter)
  255. if not success:
  256. raise HTTPException(500, "Failed to send K-profiles batch command")
  257. ok, detail = await client.await_cali_ack(success)
  258. if not ok:
  259. raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
  260. return {
  261. "success": True,
  262. "confirmed": detail != CALI_ACK_UNCONFIRMED,
  263. "message": f"Added {len(profiles)} K-profiles",
  264. }
  265. @router.delete("/", response_model=dict)
  266. async def delete_kprofile(
  267. printer_id: int,
  268. profile: KProfileDelete,
  269. db: AsyncSession = Depends(get_db, scope="function"),
  270. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
  271. ):
  272. """Delete a K-profile from the printer.
  273. Args:
  274. printer_id: ID of the printer
  275. profile: K-profile identification data for deletion
  276. """
  277. # Check printer exists
  278. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  279. printer = result.scalar_one_or_none()
  280. if not printer:
  281. raise HTTPException(404, "Printer not found")
  282. # Get MQTT client for printer
  283. client = printer_manager.get_client(printer_id)
  284. if not client or not client.state.connected:
  285. raise HTTPException(400, "Printer not connected")
  286. # Send the delete command to printer
  287. logger.info(
  288. f"[API] delete_kprofile: printer={printer_id}, slot_id={profile.slot_id}, "
  289. f"setting_id={profile.setting_id}, filament_id={profile.filament_id}"
  290. )
  291. success = client.delete_kprofile(
  292. cali_idx=profile.slot_id,
  293. filament_id=profile.filament_id,
  294. nozzle_id=profile.nozzle_id,
  295. nozzle_diameter=profile.nozzle_diameter,
  296. extruder_id=profile.extruder_id,
  297. setting_id=profile.setting_id,
  298. )
  299. if not success:
  300. raise HTTPException(500, "Failed to send K-profile delete command")
  301. ok, detail = await client.await_cali_ack(success)
  302. if not ok:
  303. raise HTTPException(500, f"Printer rejected the delete: {detail}")
  304. # Wait for printer to process the delete before frontend refetches
  305. await asyncio.sleep(0.5)
  306. return {"success": True, "confirmed": detail != CALI_ACK_UNCONFIRMED, "message": "K-profile deleted successfully"}
  307. @router.get("/notes", response_model=KProfileNoteResponse)
  308. async def get_kprofile_notes(
  309. printer_id: int,
  310. db: AsyncSession = Depends(get_db, scope="function"),
  311. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
  312. ):
  313. """Get all K-profile notes for a printer.
  314. Notes are stored locally since printers don't support notes.
  315. Args:
  316. printer_id: ID of the printer
  317. """
  318. # Check printer exists
  319. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  320. printer = result.scalar_one_or_none()
  321. if not printer:
  322. raise HTTPException(404, "Printer not found")
  323. # Get all notes for this printer
  324. result = await db.execute(select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id))
  325. notes = result.scalars().all()
  326. # Return as a dictionary mapping setting_id -> note
  327. return KProfileNoteResponse(notes={note.setting_id: note.note for note in notes})
  328. @router.put("/notes", response_model=dict)
  329. async def set_kprofile_note(
  330. printer_id: int,
  331. note_data: KProfileNote,
  332. db: AsyncSession = Depends(get_db, scope="function"),
  333. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
  334. ):
  335. """Set or update a note for a K-profile.
  336. Args:
  337. printer_id: ID of the printer
  338. note_data: The note data (setting_id and note content)
  339. """
  340. # Check printer exists
  341. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  342. printer = result.scalar_one_or_none()
  343. if not printer:
  344. raise HTTPException(404, "Printer not found")
  345. # Find existing note or create new one
  346. result = await db.execute(
  347. select(KProfileNoteModel).where(
  348. KProfileNoteModel.printer_id == printer_id,
  349. KProfileNoteModel.setting_id == note_data.setting_id,
  350. )
  351. )
  352. existing_note = result.scalar_one_or_none()
  353. if note_data.note.strip():
  354. # Save or update note
  355. if existing_note:
  356. existing_note.note = note_data.note
  357. else:
  358. new_note = KProfileNoteModel(
  359. printer_id=printer_id,
  360. setting_id=note_data.setting_id,
  361. note=note_data.note,
  362. )
  363. db.add(new_note)
  364. await db.commit()
  365. return {"success": True, "message": "Note saved"}
  366. else:
  367. # Delete note if empty
  368. if existing_note:
  369. await db.delete(existing_note)
  370. await db.commit()
  371. return {"success": True, "message": "Note deleted"}
  372. @router.delete("/notes/{setting_id}", response_model=dict)
  373. async def delete_kprofile_note(
  374. printer_id: int,
  375. setting_id: str,
  376. db: AsyncSession = Depends(get_db, scope="function"),
  377. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
  378. ):
  379. """Delete a note for a K-profile.
  380. Args:
  381. printer_id: ID of the printer
  382. setting_id: The setting_id of the K-profile
  383. """
  384. # Check printer exists
  385. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  386. printer = result.scalar_one_or_none()
  387. if not printer:
  388. raise HTTPException(404, "Printer not found")
  389. # Find and delete the note
  390. result = await db.execute(
  391. select(KProfileNoteModel).where(
  392. KProfileNoteModel.printer_id == printer_id,
  393. KProfileNoteModel.setting_id == setting_id,
  394. )
  395. )
  396. existing_note = result.scalar_one_or_none()
  397. if existing_note:
  398. await db.delete(existing_note)
  399. await db.commit()
  400. return {"success": True, "message": "Note deleted"}