kprofile_drift.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. """Put a slot's stored K-profile back when the printer has lost the selection.
  2. A tray's ``cali_idx`` can fall back to -1 without anything else in the AMS
  3. report changing: an X1 Carbon power-cycled mid-print came back with every slot
  4. on the default K while spools, tags and remain% were exactly as before (#3219).
  5. ``on_ams_change`` is keyed on tray_type / tag_uid / remain, so it never saw
  6. that, and two queued jobs went out on the default K.
  7. This module re-selects the stored profile for such a slot. It acts only when
  8. the selection is *lost* (``cali_idx`` -1 or missing), never when the slot
  9. holds a different real profile: that one was picked on purpose, in Bambu
  10. Studio for example. A profile picked in Bambuddy's Configure Slot dialog is
  11. saved as the spool's stored profile anyway, but a "Default" pick there is not
  12. saved anywhere, so it is recorded through ``note_slot_configured`` and left
  13. alone too.
  14. Two callers: the status handler, on every push while the printer is idle, and
  15. the print scheduler, just before it sends a job.
  16. """
  17. import logging
  18. import time
  19. from dataclasses import dataclass
  20. from backend.app.core.database import async_session
  21. from backend.app.services.printer_manager import printer_manager
  22. from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
  23. from backend.app.services.slot_nozzle import resolve_slot_nozzle
  24. from backend.app.services.spool_filament_preset import printer_safe_filament_id
  25. from backend.app.utils.printer_models import is_dual_nozzle_model
  26. from backend.app.utils.tray_ids import EXTERNAL_AMS_ID, TrayLayout, external_slot_index
  27. logger = logging.getLogger(__name__)
  28. # States in which re-selecting a profile cannot touch a print. PREPARE and
  29. # SLICING are left out on purpose: the printer is starting a job there.
  30. IDLE_STATES = frozenset({"IDLE", "FINISH", "FAILED"})
  31. # Time between two re-selections of one slot: the push right after a send can
  32. # still show the old value, so an immediate resend would only be noise.
  33. RETRY_INTERVAL_S = 30.0
  34. # Time before a slot with no stored profile is looked up again. Most slots on
  35. # the default K are there on purpose, and on an install with many printers a
  36. # lookup per slot every 30 seconds would add up for nothing. A reconnect (the
  37. # #3219 case) clears this, so a power cycle is still caught at once.
  38. NO_PROFILE_RECHECK_S = 600.0
  39. # Re-selections of one profile before giving up on it. A stored cali_idx the
  40. # printer no longer has will never stick; this keeps that from repeating forever.
  41. MAX_ATTEMPTS = 3
  42. SlotKey = tuple[int, int, int]
  43. @dataclass
  44. class _SlotWatch:
  45. next_check: float = 0.0
  46. attempts: int = 0
  47. gave_up_logged: bool = False
  48. _watches: dict[SlotKey, _SlotWatch] = {}
  49. _default_chosen: set[SlotKey] = set()
  50. _running: set[int] = set()
  51. def note_slot_configured(printer_id: int, ams_id: int, tray_id: int, cali_idx: int) -> None:
  52. """Record a K-profile picked for a slot in Bambuddy's Configure Slot dialog.
  53. A default pick (-1) is respected until the slot is emptied or configured
  54. again. It is not cleared by the slot reporting a real profile, because the
  55. push right after the pick can still carry the old one.
  56. """
  57. key = (printer_id, ams_id, tray_id)
  58. if cali_idx < 0:
  59. _default_chosen.add(key)
  60. else:
  61. _default_chosen.discard(key)
  62. def forget_printer(printer_id: int, *, deleted: bool = False) -> None:
  63. """Drop the retry bookkeeping for a printer, e.g. when it disconnects.
  64. A reconnect is a fresh start: a profile given up on before may stick now.
  65. A deliberate default pick is kept -- it still describes the slot -- unless
  66. the printer was deleted: then it describes nothing, and SQLite gives the id
  67. to the next printer created.
  68. """
  69. for key in [k for k in _watches if k[0] == printer_id]:
  70. del _watches[key]
  71. if deleted:
  72. for key in [k for k in _default_chosen if k[0] == printer_id]:
  73. _default_chosen.discard(key)
  74. def _selection_lost(tray: dict) -> bool:
  75. cali_idx = tray.get("cali_idx")
  76. if cali_idx is None:
  77. return True
  78. try:
  79. return int(cali_idx) < 0
  80. except (TypeError, ValueError):
  81. return False
  82. def _loaded_trays(state) -> list[tuple[int, int, int, dict]]:
  83. """``(ams_id, tray_id, global_tray_id, tray)`` for every loaded slot.
  84. External spools use the assignment addressing (ams 255, tray 0/1) and the
  85. global id the print command carries for them (254/255).
  86. """
  87. raw = getattr(state, "raw_data", None) or {}
  88. ams_raw = raw.get("ams")
  89. units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  90. layout = TrayLayout.from_state(state)
  91. trays: list[tuple[int, int, int, dict]] = []
  92. for unit in units:
  93. if not isinstance(unit, dict):
  94. continue
  95. try:
  96. ams_id = int(unit.get("id", 0))
  97. except (TypeError, ValueError):
  98. continue
  99. for tray in unit.get("tray") or []:
  100. if not isinstance(tray, dict):
  101. continue
  102. try:
  103. tray_id = int(tray.get("id", 0))
  104. except (TypeError, ValueError):
  105. continue
  106. trays.append((ams_id, tray_id, layout.global_tray_id(ams_id, tray_id), tray))
  107. vt_raw = raw.get("vt_tray")
  108. if isinstance(vt_raw, dict):
  109. vt_raw = [vt_raw]
  110. for vt in vt_raw if isinstance(vt_raw, list) else []:
  111. if not isinstance(vt, dict):
  112. continue
  113. try:
  114. vt_id = int(vt.get("id", 254))
  115. except (TypeError, ValueError):
  116. continue
  117. if layout.is_external(vt_id):
  118. trays.append((EXTERNAL_AMS_ID, external_slot_index(vt_id), vt_id, vt))
  119. return trays
  120. def _settle(key: SlotKey, tray: dict) -> bool:
  121. """Clear bookkeeping a slot no longer needs; True if it is a candidate.
  122. A candidate is a loaded slot whose selection is lost and was not set to
  123. the default on purpose.
  124. """
  125. if not tray.get("tray_type"):
  126. _watches.pop(key, None)
  127. _default_chosen.discard(key)
  128. return False
  129. if not _selection_lost(tray):
  130. _watches.pop(key, None)
  131. return False
  132. return key not in _default_chosen
  133. def _due(printer_id: int, key: SlotKey, now: float) -> bool:
  134. watch = _watches.get(key)
  135. if watch is None:
  136. return True
  137. if now < watch.next_check:
  138. return False
  139. if watch.attempts < MAX_ATTEMPTS:
  140. return True
  141. # The last re-selection had its interval to land and the slot is still
  142. # lost: say so once, then leave the slot alone until it changes.
  143. if not watch.gave_up_logged:
  144. watch.gave_up_logged = True
  145. logger.warning(
  146. "[Printer %s] AMS%d-T%d still has no K-profile after %d re-selections of the stored "
  147. "one; giving up until the slot changes. Check that the profile still exists on the printer.",
  148. printer_id,
  149. key[1],
  150. key[2],
  151. MAX_ATTEMPTS,
  152. )
  153. return False
  154. def needs_check(printer_id: int, state) -> bool:
  155. """Cheap test the status handler runs on every push before spawning a check."""
  156. if printer_id in _running:
  157. return False
  158. if not getattr(state, "connected", False):
  159. return False
  160. if (getattr(state, "state", "") or "").upper() not in IDLE_STATES:
  161. return False
  162. now = time.monotonic()
  163. found = False
  164. for ams_id, tray_id, _global_id, tray in _loaded_trays(state):
  165. key = (printer_id, ams_id, tray_id)
  166. if _settle(key, tray) and _due(printer_id, key, now):
  167. found = True
  168. return found
  169. async def reapply_lost_kprofiles(
  170. printer_id: int, global_trays: set[int] | None = None, *, throttle: bool = True
  171. ) -> int:
  172. """Re-select the stored K-profile on every slot whose selection was lost.
  173. ``global_trays`` limits the pass to the trays a print uses. ``throttle``
  174. off is for the dispatch guard: a job about to start must not wait out a
  175. retry interval, and that runs once per job anyway. Returns how many slots
  176. were re-selected.
  177. """
  178. # The dispatch guard runs regardless: a pass already in flight may have
  179. # skipped this job's trays on its retry interval.
  180. if throttle and printer_id in _running:
  181. return 0
  182. state = printer_manager.get_status(printer_id)
  183. client = printer_manager.get_client(printer_id)
  184. if state is None or client is None or not getattr(state, "connected", False):
  185. return 0
  186. model = printer_manager.get_model(printer_id)
  187. nozzles = getattr(state, "nozzles", None) or []
  188. dual_nozzle = is_dual_nozzle_model(model) or (len(nozzles) > 1 and bool(getattr(nozzles[1], "nozzle_diameter", "")))
  189. now = time.monotonic()
  190. candidates = []
  191. for ams_id, tray_id, global_id, tray in _loaded_trays(state):
  192. key = (printer_id, ams_id, tray_id)
  193. if not _settle(key, tray):
  194. continue
  195. if global_trays is not None and global_id not in global_trays:
  196. continue
  197. if throttle and not _due(printer_id, key, now):
  198. continue
  199. # Claimed before the first await, so a push arriving meanwhile does
  200. # not pick the same slot up a second time.
  201. _watches.setdefault(key, _SlotWatch()).next_check = now + RETRY_INTERVAL_S
  202. candidates.append((key, ams_id, tray_id, tray))
  203. if not candidates:
  204. return 0
  205. applied = 0
  206. if throttle:
  207. _running.add(printer_id)
  208. try:
  209. async with async_session() as db:
  210. for key, ams_id, tray_id, tray in candidates:
  211. slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, model)
  212. if dual_nozzle:
  213. if slot_nozzle.extruder is None:
  214. # Unknown nozzle on a dual machine: picking extruder 0
  215. # would be a guess, and a wrong guess binds the other
  216. # hotend's calibration.
  217. continue
  218. extruders = [slot_nozzle.extruder]
  219. else:
  220. # One nozzle, but not one extruder number: the external
  221. # holder resolves to 1 (it is H2D's Ext-L), and Configure
  222. # Slot stores the profile under that, while the spool form
  223. # stores 0. Both mean the only nozzle there is.
  224. extruders = list(dict.fromkeys([0, slot_nozzle.extruder_or_default]))
  225. profile = None
  226. for extruder in extruders:
  227. profile = await find_slot_kprofile_for_extruder(
  228. db,
  229. printer_id,
  230. ams_id,
  231. tray_id,
  232. extruder,
  233. slot_nozzle.diameter,
  234. model,
  235. slot_nozzle.flow,
  236. )
  237. if profile is not None:
  238. break
  239. if profile is None or profile.cali_idx is None or profile.cali_idx < 0:
  240. _watches.setdefault(key, _SlotWatch()).next_check = now + NO_PROFILE_RECHECK_S
  241. continue
  242. client.extrusion_cali_sel(
  243. ams_id=ams_id,
  244. tray_id=tray_id,
  245. cali_idx=profile.cali_idx,
  246. filament_id=printer_safe_filament_id(profile.filament_id, tray.get("tray_info_idx")),
  247. nozzle_diameter=slot_nozzle.diameter,
  248. )
  249. applied += 1
  250. watch = _watches.setdefault(key, _SlotWatch())
  251. watch.attempts += 1
  252. logger.info(
  253. "[Printer %s] AMS%d-T%d lost its K-profile (cali_idx=%s): re-selected stored "
  254. "profile %r (cali_idx=%d), attempt %d",
  255. printer_id,
  256. ams_id,
  257. tray_id,
  258. tray.get("cali_idx"),
  259. profile.name,
  260. profile.cali_idx,
  261. watch.attempts,
  262. )
  263. except Exception:
  264. logger.exception("[Printer %s] K-profile re-selection failed", printer_id)
  265. finally:
  266. if throttle:
  267. _running.discard(printer_id)
  268. return applied