spoolman_tracking.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309
  1. """Spoolman per-filament usage tracking for active prints.
  2. Captures AMS tray state and G-code data at print start, then reports
  3. per-filament usage to the correct Spoolman spools at print completion.
  4. Supports accurate partial usage reporting for failed/cancelled prints.
  5. """
  6. import json
  7. import logging
  8. from sqlalchemy import delete, select
  9. from backend.app.core.config import settings as app_settings
  10. from backend.app.core.database import async_session
  11. from backend.app.services.spoolman import (
  12. SpoolmanClientError,
  13. SpoolmanNotFoundError,
  14. SpoolmanUnavailableError,
  15. get_spoolman_client,
  16. init_spoolman_client,
  17. )
  18. logger = logging.getLogger(__name__)
  19. # Zero UUID used by Bambu printers for empty/unset tray_uuid
  20. _ZERO_UUID = "00000000000000000000000000000000"
  21. _ZERO_TAG_UID = "0000000000000000"
  22. def _is_non_zero_identifier(value: str) -> bool:
  23. """Return True when identifier is non-empty and not all zeros."""
  24. if not value:
  25. return False
  26. return set(value) != {"0"}
  27. def _to_fixed_hex(value: int, width: int) -> str:
  28. """Mirror frontend toFixedHex(): uppercase, zero-padded, fixed width."""
  29. safe = max(0, int(value))
  30. return format(safe, "X").zfill(width)[-width:]
  31. def _hash_serial_to_hex32(serial: str) -> str:
  32. """Mirror frontend hashSerialToHex32() exactly (32-bit FNV-1a)."""
  33. input_str = (serial or "").strip().upper()
  34. hash_value = 0x811C9DC5
  35. for char in input_str:
  36. hash_value ^= ord(char)
  37. hash_value = (hash_value * 0x01000193) & 0xFFFFFFFF
  38. return format(hash_value, "X").zfill(8)
  39. def _global_tray_id_to_ams_slot(global_tray_id: int) -> tuple[int, int]:
  40. """Convert global tray id to (ams_id, tray_id) tuple for fallback tag generation."""
  41. # External spool slots use IDs 254/255 and map to ams_id=255 tray_id=0/1.
  42. if global_tray_id >= 254:
  43. return 255, max(0, global_tray_id - 254)
  44. # AMS-HT units are addressed by ams_id directly and have a single tray.
  45. if global_tray_id >= 128:
  46. return global_tray_id, 0
  47. # Standard AMS units: four trays each.
  48. return global_tray_id // 4, global_tray_id % 4
  49. def _get_fallback_spool_tag(printer_serial: str, global_tray_id: int) -> str:
  50. """Mirror frontend getFallbackSpoolTag(serial, amsId, trayId) exactly."""
  51. if not printer_serial:
  52. return ""
  53. ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
  54. return get_fallback_spool_tag_for_slot(printer_serial, ams_id, tray_id)
  55. def get_fallback_spool_tag_for_slot(printer_serial: str, ams_id: int, tray_id: int) -> str:
  56. """Public helper matching frontend getFallbackSpoolTag(serial, amsId, trayId).
  57. Used by stale-tag cleanup (#1457) to detect Spoolman spools still holding
  58. this slot's deterministic fallback tag in extra.tag.
  59. """
  60. if not printer_serial:
  61. return ""
  62. return f"{_hash_serial_to_hex32(printer_serial)}{_to_fixed_hex(ams_id, 4)}{_to_fixed_hex(tray_id, 4)}"
  63. def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id: int | None = None) -> str:
  64. """Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).
  65. Returns empty string if no usable identifier is found.
  66. """
  67. tray_uuid = str(tray_info.get("tray_uuid", "") or "")
  68. tag_uid = str(tray_info.get("tag_uid", "") or "")
  69. if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid):
  70. return tray_uuid
  71. if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
  72. return tag_uid
  73. if global_tray_id is not None:
  74. return _get_fallback_spool_tag(printer_serial, global_tray_id)
  75. return ""
  76. async def _get_printer_serial(printer_id: int) -> str:
  77. """Get printer serial for deterministic fallback tag generation."""
  78. from backend.app.models.printer import Printer
  79. from backend.app.services.printer_manager import printer_manager
  80. printer_info = printer_manager.get_printer(printer_id)
  81. if printer_info and printer_info.serial_number:
  82. return printer_info.serial_number
  83. async with async_session() as db:
  84. result = await db.execute(select(Printer.serial_number).where(Printer.id == printer_id))
  85. serial_number = result.scalar_one_or_none()
  86. return serial_number or ""
  87. def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays: dict | None = None) -> int:
  88. """Map a 1-based slot_id to a global_tray_id using optional custom mapping.
  89. Custom mapping: slot_to_tray[slot_id - 1] is used when >= 0.
  90. A value of -1 in the custom mapping means the slicer routed this slot to
  91. the external spool. BambuStudio converts virtual tray IDs (254/255) to -1
  92. in the flat ams_mapping array before sending to the printer — see
  93. start_print() in bambu_mqtt.py which documents this convention. We mirror
  94. it here: when -1 is seen, look up the external spool's actual
  95. global_tray_id (254/255) in ams_trays rather than falling through to the
  96. position-based default (which would map slot_id=1 to the first AMS tray
  97. and credit an unrelated spool — see #1276, regression of #853).
  98. Position-based default: uses sorted ams_trays keys so external spools (ID 254/255)
  99. naturally follow standard AMS trays, matching the slicer's slot numbering.
  100. Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools).
  101. """
  102. if slot_to_tray and slot_id <= len(slot_to_tray):
  103. mapped_tray = slot_to_tray[slot_id - 1]
  104. if mapped_tray >= 0:
  105. return mapped_tray
  106. if mapped_tray == -1 and ams_trays:
  107. # -1 means external spool. 254 = VIRTUAL_TRAY_DEPUTY_ID (main on
  108. # single-nozzle, left/deputy on H2D dual-nozzle); 255 =
  109. # VIRTUAL_TRAY_MAIN_ID. Prefer 254 when both exist since that's
  110. # what single-nozzle printers report via tray_now.
  111. for ext_id in (254, 255):
  112. if ext_id in ams_trays:
  113. return ext_id
  114. # Position-based default: sort available tray IDs so external spools (254/255)
  115. # come after standard AMS trays, matching the slicer's slot assignment order.
  116. if ams_trays:
  117. sorted_tray_ids = sorted(ams_trays.keys())
  118. if slot_id <= len(sorted_tray_ids):
  119. return sorted_tray_ids[slot_id - 1]
  120. return slot_id - 1
  121. def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
  122. """Build lookup of global_tray_id -> tray info from printer state.
  123. Returns: {0: {"tray_uuid": "...", "tag_uid": "...", "tray_type": "..."}, ...}
  124. """
  125. lookup = {}
  126. ams_data = raw_data.get("ams", [])
  127. for ams_unit in ams_data:
  128. ams_id = int(ams_unit.get("id", 0))
  129. for tray in ams_unit.get("tray", []):
  130. tray_id = int(tray.get("id", 0))
  131. # AMS-HT units have IDs starting at 128 with a single tray
  132. global_tray_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id
  133. lookup[global_tray_id] = {
  134. "tray_uuid": tray.get("tray_uuid", ""),
  135. "tag_uid": tray.get("tag_uid", ""),
  136. "tray_type": tray.get("tray_type", ""),
  137. }
  138. # External spool(s) (vt_tray is a list, global_tray_id from each entry's "id")
  139. for vt in raw_data.get("vt_tray") or []:
  140. if vt.get("tray_type"):
  141. tray_id = int(vt.get("id", 254))
  142. lookup[tray_id] = {
  143. "tray_uuid": vt.get("tray_uuid", ""),
  144. "tag_uid": vt.get("tag_uid", ""),
  145. "tray_type": vt.get("tray_type", ""),
  146. }
  147. return lookup
  148. def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]:
  149. """Capture per-slot ``remain%`` + ``tray_uuid`` at print start so the
  150. completion path can compute a remain-delta when 3MF data doesn't cover
  151. the slot (or there's no 3MF at all — #1820).
  152. Returns ``{"<ams_id>-<tray_id>": {"remain": int, "tray_uuid": str}}``.
  153. Only slots whose ``remain`` is a valid 0..100 int are included; invalid
  154. values mean the AMS hasn't read the spool yet and a delta would be
  155. meaningless. Mirrors the gate in
  156. ``usage_tracker.on_print_start:309``.
  157. """
  158. snapshot: dict[str, dict] = {}
  159. ams_raw = raw_data.get("ams", [])
  160. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  161. for ams_unit in ams_data:
  162. if not isinstance(ams_unit, dict):
  163. continue
  164. ams_id = int(ams_unit.get("id", 0))
  165. for tray in ams_unit.get("tray", []):
  166. if not isinstance(tray, dict):
  167. continue
  168. tray_id = int(tray.get("id", 0))
  169. remain = tray.get("remain", -1)
  170. if isinstance(remain, int) and 0 <= remain <= 100:
  171. snapshot[f"{ams_id}-{tray_id}"] = {
  172. "remain": remain,
  173. "tray_uuid": tray.get("tray_uuid", "") or "",
  174. }
  175. vt_tray_raw = raw_data.get("vt_tray") or []
  176. if isinstance(vt_tray_raw, dict):
  177. vt_tray_raw = [vt_tray_raw]
  178. for vt in vt_tray_raw:
  179. if not isinstance(vt, dict):
  180. continue
  181. vt_id = int(vt.get("id", 254))
  182. # 254 → (255, 0), 255 → (255, 1) — matches usage_tracker's encoding.
  183. vt_tray_id = vt_id - 254
  184. remain = vt.get("remain", -1)
  185. if isinstance(remain, int) and 0 <= remain <= 100:
  186. snapshot[f"255-{vt_tray_id}"] = {
  187. "remain": remain,
  188. "tray_uuid": vt.get("tray_uuid", "") or "",
  189. }
  190. return snapshot
  191. async def store_print_data(
  192. printer_id: int,
  193. archive_id: int,
  194. file_path: str,
  195. db,
  196. printer_manager,
  197. ams_mapping: list[int] | None = None,
  198. plate_id: int | None = None,
  199. ):
  200. """Store Spoolman tracking data at print start (persisted to database).
  201. Per-print tracking is the primary weight-update path for Spoolman, mirroring
  202. how the internal Filament Inventory works. The legacy AMS-remain%-based sync
  203. is no longer used as a weight writer (#1119), so this runs whenever Spoolman
  204. is enabled regardless of the deprecated `spoolman_disable_weight_sync` flag.
  205. ``plate_id``, when set, scopes the 3MF filament extract to a single plate so
  206. queue / direct-Print dispatch of plate N of a multi-plate file doesn't
  207. attribute every plate's filament to the printed spool (#1697). When unset,
  208. the queue item's plate_id (if any) is used; otherwise the whole-file sum is
  209. extracted, which is correct for direct prints that target the first/only
  210. plate of a single-plate file.
  211. """
  212. from backend.app.api.routes.settings import get_setting
  213. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  214. from backend.app.models.print_queue import PrintQueueItem
  215. from backend.app.utils.threemf_tools import (
  216. extract_filament_properties_from_3mf,
  217. extract_filament_usage_from_3mf,
  218. extract_layer_filament_usage_from_3mf,
  219. )
  220. # Check if Spoolman is enabled
  221. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  222. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  223. return
  224. # Get current AMS tray state up front — needed both for the 3MF path's
  225. # ams_trays field and for the remain%-delta snapshot (#1820 fallback for
  226. # no-3MF "Untitled" prints, mirroring usage_tracker.on_print_start).
  227. state = printer_manager.get_status(printer_id)
  228. ams_trays: dict[int, dict] = {}
  229. tray_remain_start: dict[str, dict] = {}
  230. if state and state.raw_data:
  231. ams_trays = build_ams_tray_lookup(state.raw_data)
  232. tray_remain_start = _snapshot_tray_remain(state.raw_data)
  233. # Try to read per-slot filament estimates from the 3MF. Two paths can
  234. # leave ``filament_usage`` empty: (1) fallback archive (no .gcode.3mf
  235. # was downloadable from the printer — "Untitled" prints, see #1820),
  236. # (2) 3MF present but slice_info missing per-filament estimates.
  237. # Both fall through to the remain%-delta path at completion.
  238. filament_usage: list | None = None
  239. layer_usage_json: dict | None = None
  240. filament_properties: dict | None = None
  241. full_path = (
  242. app_settings.base_dir / file_path
  243. ) # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated
  244. threemf_available = bool(file_path) and full_path.exists()
  245. queue_item = None
  246. if threemf_available:
  247. # Resolve the queue item once — used both for the plate-scoped 3MF parsing
  248. # fallback (#1697: multi-plate file dispatched for one plate must only count
  249. # that plate's filament) and for the ams_mapping fallback below.
  250. queue_result = await db.execute(
  251. select(PrintQueueItem)
  252. .where(PrintQueueItem.archive_id == archive_id)
  253. .where(PrintQueueItem.status == "printing")
  254. )
  255. queue_item = queue_result.scalar_one_or_none()
  256. # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
  257. # item's plate_id (queue dispatch path).
  258. effective_plate_id = (
  259. plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
  260. )
  261. filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id) or None
  262. layer_usage = extract_layer_filament_usage_from_3mf(full_path)
  263. if layer_usage:
  264. # Convert int keys to string for JSON serialization
  265. layer_usage_json = {str(k): v for k, v in layer_usage.items()}
  266. logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
  267. filament_properties = extract_filament_properties_from_3mf(full_path)
  268. else:
  269. # No 3MF on disk — common for "Untitled" prints whose .gcode.3mf
  270. # was never on the printer's FTP. Logged at debug since the
  271. # fallback path below picks up the slack when remain% is available.
  272. logger.debug("[SPOOLMAN] 3MF file not available: %s", full_path)
  273. # If neither path has anything useful, there's nothing to track.
  274. if not filament_usage and not tray_remain_start:
  275. if threemf_available:
  276. logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
  277. return
  278. # Prefer the explicit mapping captured from the print command, then fall back
  279. # to any queue mapping stored for scheduled/reprint jobs.
  280. slot_to_tray = ams_mapping if ams_mapping is not None else None
  281. if not slot_to_tray and queue_item and queue_item.ams_mapping:
  282. try:
  283. slot_to_tray = json.loads(queue_item.ams_mapping)
  284. except json.JSONDecodeError:
  285. pass # Ignore malformed AMS mapping; fall back to default slot assignment
  286. # Delete any existing row for this printer/archive (shouldn't exist, but just in case)
  287. await db.execute(
  288. delete(ActivePrintSpoolman)
  289. .where(ActivePrintSpoolman.printer_id == printer_id)
  290. .where(ActivePrintSpoolman.archive_id == archive_id)
  291. )
  292. # Insert new tracking data. ``filament_usage`` may be None for the
  293. # no-3MF case; report_usage falls back to ``tray_remain_start``.
  294. tracking = ActivePrintSpoolman(
  295. printer_id=printer_id,
  296. archive_id=archive_id,
  297. filament_usage=filament_usage,
  298. ams_trays=ams_trays,
  299. slot_to_tray=slot_to_tray,
  300. layer_usage=layer_usage_json,
  301. filament_properties=filament_properties,
  302. tray_remain_start=tray_remain_start or None,
  303. )
  304. db.add(tracking)
  305. await db.commit()
  306. logger.info(
  307. "[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s (3mf=%s, remain_snapshot=%d slot(s))",
  308. printer_id,
  309. archive_id,
  310. "yes" if filament_usage else "no",
  311. len(tray_remain_start),
  312. )
  313. logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
  314. logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
  315. if slot_to_tray:
  316. logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
  317. if layer_usage_json:
  318. logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
  319. async def cleanup_tracking(
  320. printer_id: int,
  321. archive_id: int,
  322. db,
  323. last_layer_num: int | None = None,
  324. last_progress: int | None = None,
  325. ):
  326. """Report partial usage and clean up Spoolman tracking data for failed/aborted prints."""
  327. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  328. # Get tracking data first (needed for partial usage reporting)
  329. result = await db.execute(
  330. select(ActivePrintSpoolman)
  331. .where(ActivePrintSpoolman.printer_id == printer_id)
  332. .where(ActivePrintSpoolman.archive_id == archive_id)
  333. )
  334. tracking = result.scalar_one_or_none()
  335. if not tracking:
  336. logger.debug("[SPOOLMAN] No tracking data to clean up for printer=%s, archive=%s", printer_id, archive_id)
  337. return
  338. # Try to report partial usage before cleanup
  339. try:
  340. await _report_partial_usage(
  341. printer_id,
  342. tracking,
  343. last_layer_num=last_layer_num,
  344. last_progress=last_progress,
  345. )
  346. except Exception as e:
  347. logger.warning("[SPOOLMAN] Partial usage report failed: %s", e)
  348. # Delete tracking data
  349. await db.execute(
  350. delete(ActivePrintSpoolman)
  351. .where(ActivePrintSpoolman.printer_id == printer_id)
  352. .where(ActivePrintSpoolman.archive_id == archive_id)
  353. )
  354. await db.commit()
  355. logger.debug("[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s", printer_id, archive_id)
  356. async def _get_spoolman_client_with_fallback():
  357. """Get Spoolman client, initializing from settings if needed.
  358. Returns (client, is_healthy) tuple. Client may be None.
  359. """
  360. client = await get_spoolman_client()
  361. if not client:
  362. async with async_session() as db:
  363. from backend.app.api.routes.settings import get_setting
  364. spoolman_url = await get_setting(db, "spoolman_url")
  365. if spoolman_url:
  366. try:
  367. client = await init_spoolman_client(spoolman_url)
  368. except ValueError as exc:
  369. logger.warning("Spoolman URL %r rejected by SSRF guard: %s", spoolman_url, exc)
  370. return None
  371. if not client:
  372. return None
  373. if not await client.health_check():
  374. logger.warning("Spoolman health check failed; skipping usage reporting")
  375. return None
  376. return client
  377. async def _resolve_spool_id_via_slot_assignment(printer_id: int, ams_id: int, tray_id: int) -> int | None:
  378. """Look up the Spoolman spool ID locally bound to (printer, ams, tray).
  379. Fallback path for #1459: when a tag-less spool was assigned via the
  380. Bambuddy UI, the user's deterministic fallback tag is intentionally NOT
  381. written to Spoolman's extra.tag (kept clean per #1457), so
  382. find_spool_by_tag misses. The local spoolman_slot_assignments table is
  383. the authoritative binding for those spools.
  384. """
  385. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  386. async with async_session() as db:
  387. result = await db.execute(
  388. select(SpoolmanSlotAssignment.spoolman_spool_id).where(
  389. SpoolmanSlotAssignment.printer_id == printer_id,
  390. SpoolmanSlotAssignment.ams_id == ams_id,
  391. SpoolmanSlotAssignment.tray_id == tray_id,
  392. )
  393. )
  394. return result.scalar_one_or_none()
  395. async def _report_spool_usage_for_slots(
  396. client,
  397. filament_usage_items: list[tuple[int, float]],
  398. ams_trays: dict[int, dict],
  399. slot_to_tray: list | None,
  400. method_label: str,
  401. printer_serial: str = "",
  402. printer_id: int | None = None,
  403. slot_colors_out: dict[int, str] | None = None,
  404. slot_materials_out: dict[int, str] | None = None,
  405. ) -> int:
  406. """Report usage to Spoolman for a list of (slot_id, grams) pairs.
  407. Resolution order per slot: (1) Spoolman extra.tag match against the
  408. tray's RFID or deterministic fallback tag, (2) #1459 fallback —
  409. local spoolman_slot_assignments table keyed by (printer_id, ams_id,
  410. tray_id). Without (2), tag-less spools assigned via the Bambuddy UI
  411. never get their weight decremented because their extra.tag is empty
  412. on the Spoolman side.
  413. When ``slot_colors_out`` is provided it is populated with
  414. ``{slot_id: color_hex}`` for every resolved spool — used by
  415. :func:`report_usage` to stamp the archive's filament colour from the
  416. Spoolman spool rather than the slicer's 3MF value (#1494).
  417. Returns number of spools successfully updated.
  418. """
  419. spools_updated = 0
  420. for slot_id, grams_used in filament_usage_items:
  421. if grams_used <= 0:
  422. continue
  423. global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
  424. tray_info = ams_trays.get(global_tray_id)
  425. if not tray_info:
  426. logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
  427. continue
  428. is_external = global_tray_id >= 254
  429. tray_type = tray_info.get("tray_type", "")
  430. logger.debug(
  431. "[SPOOLMAN] Slot %s resolved to global_tray_id %s (tray_type=%s, external=%s)",
  432. slot_id,
  433. global_tray_id,
  434. tray_type or "unknown",
  435. is_external,
  436. )
  437. spool_id_to_use: int | None = None
  438. resolution_path = ""
  439. # color_hex + material of the resolved spool's filament, for the #1494
  440. # archive colour rewrite and the #2563 type rewrite. The tag path
  441. # already has the full spool object; the slot-assignment path only
  442. # yields an id and is fetched below.
  443. spool_color_hex: str | None = None
  444. spool_material: str | None = None
  445. spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
  446. if spool_tag:
  447. spool = await client.find_spool_by_tag(spool_tag)
  448. if spool:
  449. spool_id_to_use = spool["id"]
  450. resolution_path = "tag"
  451. spool_color_hex = (spool.get("filament") or {}).get("color_hex")
  452. spool_material = (spool.get("filament") or {}).get("material")
  453. if spool_id_to_use is None and printer_id is not None:
  454. ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
  455. spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
  456. if spool_id_to_use is not None:
  457. resolution_path = "slot-assignment"
  458. if spool_id_to_use is None:
  459. logger.debug(
  460. "[SPOOLMAN] Slot %s: no spool resolved (tag=%s, no slot-assignment)",
  461. slot_id,
  462. spool_tag[:16] if spool_tag else "none",
  463. )
  464. continue
  465. # Record the spool's filament colour + material for the archive
  466. # rewrites (#1494, #2563). The slot-assignment path resolved only an
  467. # id, so fetch the spool once for whichever value is still missing.
  468. # Strictly best-effort: a fetch failure must never abort the weight
  469. # reporting for the remaining slots, so the catch is broad.
  470. if slot_colors_out is not None or slot_materials_out is not None:
  471. need_color = slot_colors_out is not None and spool_color_hex is None
  472. need_material = slot_materials_out is not None and spool_material is None
  473. if need_color or need_material:
  474. try:
  475. _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
  476. if need_color:
  477. spool_color_hex = _fil.get("color_hex")
  478. if need_material:
  479. spool_material = _fil.get("material")
  480. except Exception as exc: # noqa: BLE001 — colour/material are non-critical
  481. logger.debug("[SPOOLMAN] Slot %s: could not fetch spool filament: %s", slot_id, exc)
  482. if slot_colors_out is not None and spool_color_hex:
  483. slot_colors_out[slot_id] = spool_color_hex
  484. if slot_materials_out is not None and spool_material:
  485. slot_materials_out[slot_id] = spool_material
  486. try:
  487. await client.use_spool(spool_id_to_use, grams_used)
  488. logger.info(
  489. "[SPOOLMAN] %s: slot %s: %sg -> spool %s (via %s)",
  490. method_label,
  491. slot_id,
  492. grams_used,
  493. spool_id_to_use,
  494. resolution_path,
  495. )
  496. spools_updated += 1
  497. except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
  498. logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool_id_to_use, exc)
  499. return spools_updated
  500. async def _report_spool_usage_split_by_tray_changes(
  501. client,
  502. filament_usage: list[dict],
  503. tray_changes: list[tuple[int, int]],
  504. ams_trays: dict[int, dict],
  505. layer_usage: dict[int, dict[int, float]] | None,
  506. filament_properties: dict | None,
  507. total_layers: int,
  508. last_layer_num: int,
  509. method_label: str,
  510. printer_serial: str,
  511. printer_id: int,
  512. slot_colors_out: dict[int, str] | None = None,
  513. slot_materials_out: dict[int, str] | None = None,
  514. ) -> tuple[int, set[int]]:
  515. """Split each slot's grams across ``tray_changes`` and charge per-segment.
  516. Mirrors ``usage_tracker`` Path 1's tray-switch branch so Spoolman and
  517. the internal Spool inventory attribute mid-print AMS-backup switches
  518. identically (#1793 — reporter's origin spool was over-charged the
  519. whole print because this path didn't exist). ``compute_tray_split_grams``
  520. holds the shared segment-math; this function wraps the per-segment
  521. spool resolution + ``use_spool`` sink for the Spoolman side.
  522. Returns ``(spools_updated, handled_global_tray_ids)`` — the caller
  523. passes ``handled_global_tray_ids`` into the remain-delta fallback so
  524. a tray attributed here is not double-charged there.
  525. """
  526. from backend.app.utils.tray_split import compute_tray_split_grams
  527. spools_updated = 0
  528. handled_global_tray_ids: set[int] = set()
  529. for usage in filament_usage:
  530. slot_id = usage.get("slot_id", 0)
  531. total_weight = usage.get("used_g", 0)
  532. if total_weight <= 0 or slot_id <= 0:
  533. continue
  534. props = (filament_properties or {}).get(str(slot_id)) or (filament_properties or {}).get(slot_id) or {}
  535. segments = compute_tray_split_grams(
  536. tray_changes=tray_changes,
  537. total_weight=float(total_weight),
  538. slot_id=slot_id,
  539. layer_usage=layer_usage,
  540. density=float(props.get("density", 1.24)),
  541. diameter=float(props.get("diameter", 1.75)),
  542. total_layers=total_layers,
  543. last_layer_num=last_layer_num,
  544. )
  545. for seg_idx, tray_global, segment_grams in segments:
  546. if segment_grams <= 0:
  547. continue
  548. # Mark this tray as handled BEFORE the resolution attempt so
  549. # remain-delta doesn't double-charge it, even if we fail to
  550. # find a spool below. Matches usage_tracker behaviour: the
  551. # tray was physically fed from during this print, whether or
  552. # not Spoolman happens to have a matching row.
  553. handled_global_tray_ids.add(tray_global)
  554. tray_info = ams_trays.get(tray_global) or {}
  555. spool_id_to_use: int | None = None
  556. resolution_path = ""
  557. spool_color_hex: str | None = None
  558. spool_material: str | None = None
  559. spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else ""
  560. if spool_tag:
  561. spool = await client.find_spool_by_tag(spool_tag)
  562. if spool:
  563. spool_id_to_use = spool["id"]
  564. resolution_path = "tag"
  565. spool_color_hex = (spool.get("filament") or {}).get("color_hex")
  566. spool_material = (spool.get("filament") or {}).get("material")
  567. if spool_id_to_use is None:
  568. seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global)
  569. spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, seg_ams_id, seg_tray_id)
  570. if spool_id_to_use is not None:
  571. resolution_path = "slot-assignment"
  572. if spool_id_to_use is None:
  573. logger.info(
  574. "[SPOOLMAN] Split slot %s seg %s tray=%d: no spool resolved — %.2fg lost from split accounting",
  575. slot_id,
  576. seg_idx,
  577. tray_global,
  578. segment_grams,
  579. )
  580. continue
  581. # Colour (#1494) + material (#2563) rewrite — first segment for a
  582. # slot wins. The UI displays a single colour/type per slot, so
  583. # later segments on the same slot don't overwrite (a backup swap
  584. # can differ but the archive card stays consistent with the origin).
  585. need_color = slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex is None
  586. need_material = (
  587. slot_materials_out is not None and slot_id not in slot_materials_out and spool_material is None
  588. )
  589. if need_color or need_material:
  590. try:
  591. _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
  592. if need_color:
  593. spool_color_hex = _fil.get("color_hex")
  594. if need_material:
  595. spool_material = _fil.get("material")
  596. except Exception as exc: # noqa: BLE001 — colour/material are non-critical
  597. logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool filament: %s", slot_id, exc)
  598. if slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex:
  599. slot_colors_out[slot_id] = spool_color_hex
  600. if slot_materials_out is not None and slot_id not in slot_materials_out and spool_material:
  601. slot_materials_out[slot_id] = spool_material
  602. try:
  603. await client.use_spool(spool_id_to_use, round(segment_grams, 2))
  604. logger.info(
  605. "[SPOOLMAN] %s: slot %s seg %s tray=%d: %.2fg -> spool %s (via %s)",
  606. method_label,
  607. slot_id,
  608. seg_idx,
  609. tray_global,
  610. segment_grams,
  611. spool_id_to_use,
  612. resolution_path,
  613. )
  614. spools_updated += 1
  615. except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
  616. logger.warning(
  617. "[SPOOLMAN] Split slot %s seg %s: failed to record usage for spool %s: %s",
  618. slot_id,
  619. seg_idx,
  620. spool_id_to_use,
  621. exc,
  622. )
  623. return spools_updated, handled_global_tray_ids
  624. async def _report_partial_usage(
  625. printer_id: int,
  626. tracking,
  627. last_layer_num: int | None = None,
  628. last_progress: int | None = None,
  629. ):
  630. """Report partial filament usage based on actual G-code layer data.
  631. Uses per-layer cumulative extrusion from G-code parsing for accurate
  632. multi-material tracking. Falls back to linear interpolation if G-code
  633. data is unavailable.
  634. """
  635. from backend.app.services.printer_manager import printer_manager
  636. from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer, mm_to_grams
  637. async with async_session() as db:
  638. from backend.app.api.routes.settings import get_setting
  639. # Check if partial usage reporting is enabled (default: true)
  640. report_partial = await get_setting(db, "spoolman_report_partial_usage")
  641. if report_partial and report_partial.lower() == "false":
  642. logger.debug("[SPOOLMAN] Partial usage reporting disabled by setting")
  643. return
  644. # Check if Spoolman is enabled
  645. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  646. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  647. return
  648. # Get current printer state for layer progress.
  649. # On failed/aborted prints the firmware may already reset to IDLE with layer=0,
  650. # so we fall back to completion-time hints captured from MQTT.
  651. state = printer_manager.get_status(printer_id)
  652. current_layer = state.layer_num if state else None
  653. total_layers = state.total_layers if state else None
  654. if (not current_layer or current_layer <= 0) and last_layer_num and last_layer_num > 0:
  655. current_layer = last_layer_num
  656. logger.debug("[SPOOLMAN] Using captured last_layer_num=%s for partial usage", current_layer)
  657. progress_ratio_from_event = None
  658. if last_progress is not None:
  659. try:
  660. progress_ratio_from_event = min(max(float(last_progress), 0.0), 100.0) / 100.0
  661. except (TypeError, ValueError):
  662. progress_ratio_from_event = None
  663. if (not current_layer or current_layer <= 0) and progress_ratio_from_event and total_layers and total_layers > 0:
  664. current_layer = max(1, int(round(total_layers * progress_ratio_from_event)))
  665. logger.debug(
  666. "[SPOOLMAN] Estimated layer from last_progress=%s%% and total_layers=%s -> %s",
  667. last_progress,
  668. total_layers,
  669. current_layer,
  670. )
  671. if not current_layer or current_layer <= 0:
  672. logger.debug(
  673. "[SPOOLMAN] No progress to report (layer 0/unknown, last_layer_num=%s, last_progress=%s)",
  674. last_layer_num,
  675. last_progress,
  676. )
  677. return
  678. logger.info("[SPOOLMAN] Reporting partial usage at layer %s/%s", current_layer, total_layers or "?")
  679. # Get tracking data
  680. layer_usage = tracking.layer_usage
  681. filament_properties = tracking.filament_properties or {}
  682. filament_usage = tracking.filament_usage or []
  683. ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
  684. slot_to_tray = tracking.slot_to_tray
  685. tray_remain_start = tracking.tray_remain_start or {}
  686. printer_serial = await _get_printer_serial(printer_id)
  687. client = await _get_spoolman_client_with_fallback()
  688. if not client:
  689. logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
  690. return
  691. # No-3MF aborted print (#1820 mirror of the completion path): nothing in
  692. # filament_usage or layer_usage to base partial estimates on, but the
  693. # remain%-delta snapshot we captured at start still describes consumption
  694. # up to the abort moment. Write it the same way report_usage's fallback
  695. # does, then return — there's no 3MF-derived partial to layer on top.
  696. # ``state`` was already fetched at the top of the function for current_layer.
  697. if not filament_usage and not layer_usage and tray_remain_start:
  698. current_lookup = _snapshot_tray_remain(state.raw_data) if state and state.raw_data else {}
  699. await _report_remain_delta_for_slots(
  700. client,
  701. printer_id=printer_id,
  702. tray_remain_start=tray_remain_start,
  703. current_lookup=current_lookup,
  704. handled_global_tray_ids=set(),
  705. archive_id=getattr(tracking, "archive_id", -1),
  706. )
  707. return
  708. # Try to use accurate G-code parsed data
  709. if layer_usage:
  710. layer_usage_int = {
  711. int(layer): {int(fid): mm for fid, mm in filaments.items()} for layer, filaments in layer_usage.items()
  712. }
  713. usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
  714. if usage_mm:
  715. logger.info("[SPOOLMAN] Using G-code parsed data for layer %s", current_layer)
  716. # Build (slot_id, grams) list using Spoolman densities with 3MF fallback
  717. usage_items = []
  718. for filament_id, mm_used in usage_mm.items():
  719. slot_id = filament_id + 1 # filament_id is 0-based, slot_id is 1-based
  720. # Get density from Spoolman (most accurate), fall back to 3MF, then PLA default
  721. global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
  722. tray_info = ams_trays.get(global_tray_id)
  723. density = None
  724. diameter = 1.75
  725. if tray_info:
  726. spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
  727. if spool_tag:
  728. spool = await client.find_spool_by_tag(spool_tag)
  729. if spool:
  730. filament_data = spool.get("filament", {})
  731. density = filament_data.get("density")
  732. diameter = filament_data.get("diameter", 1.75)
  733. if not density:
  734. props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))
  735. density = props.get("density", 1.24)
  736. logger.debug("[SPOOLMAN] Using fallback density %s for slot %s", density, slot_id)
  737. grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
  738. usage_items.append((slot_id, grams_used))
  739. spools_updated = await _report_spool_usage_for_slots(
  740. client,
  741. usage_items,
  742. ams_trays,
  743. slot_to_tray,
  744. "Partial (G-code)",
  745. printer_serial,
  746. printer_id=printer_id,
  747. )
  748. if spools_updated > 0:
  749. logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
  750. return
  751. # Fallback: linear interpolation (if no G-code data available)
  752. progress_ratio = None
  753. if total_layers and total_layers > 0:
  754. progress_ratio = min(current_layer / total_layers, 1.0)
  755. elif progress_ratio_from_event is not None:
  756. progress_ratio = progress_ratio_from_event
  757. if progress_ratio is None:
  758. logger.debug(
  759. "[SPOOLMAN] Cannot use linear fallback: total_layers=%s, last_progress=%s",
  760. total_layers,
  761. last_progress,
  762. )
  763. return
  764. logger.info("[SPOOLMAN] Falling back to linear interpolation (%s)", progress_ratio)
  765. usage_items = []
  766. for usage in filament_usage:
  767. slot_id = usage.get("slot_id", 0)
  768. total_used_g = usage.get("used_g", 0)
  769. if total_used_g > 0:
  770. partial_used_g = round(total_used_g * progress_ratio, 2)
  771. usage_items.append((slot_id, partial_used_g))
  772. spools_updated = await _report_spool_usage_for_slots(
  773. client,
  774. usage_items,
  775. ams_trays,
  776. slot_to_tray,
  777. "Partial (linear)",
  778. printer_serial,
  779. printer_id=printer_id,
  780. )
  781. if spools_updated > 0:
  782. logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
  783. async def report_usage(printer_id: int, archive_id: int):
  784. """Report filament usage to Spoolman after print completion.
  785. Two writers, mirroring the internal-inventory split in usage_tracker:
  786. 1. **3MF path (primary)** — per-filament slice estimates captured at
  787. print start drive a precise per-slot ``use_spool`` call.
  788. 2. **AMS remain%-delta (fallback)** — for slots the 3MF path didn't
  789. handle (including the no-3MF "Untitled" case from #1820): compute
  790. ``start_remain - current_remain``, multiply by the resolved
  791. Spoolman filament's reference weight, and write the delta. Mirrors
  792. ``usage_tracker.on_print_complete`` Path 2 (line 517).
  793. """
  794. async with async_session() as db:
  795. from backend.app.api.routes.settings import get_setting
  796. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  797. # Get tracking data stored at print start
  798. result = await db.execute(
  799. select(ActivePrintSpoolman)
  800. .where(ActivePrintSpoolman.printer_id == printer_id)
  801. .where(ActivePrintSpoolman.archive_id == archive_id)
  802. )
  803. tracking = result.scalar_one_or_none()
  804. if not tracking:
  805. logger.info("[SPOOLMAN] No tracking data for print (printer=%s, archive=%s)", printer_id, archive_id)
  806. return
  807. filament_usage = tracking.filament_usage or []
  808. ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
  809. slot_to_tray = tracking.slot_to_tray
  810. tray_remain_start = tracking.tray_remain_start or {}
  811. # ``layer_usage`` and ``filament_properties`` were added later than
  812. # the base tracking fields; use ``getattr`` so tests that stub
  813. # ``tracking`` as a lightweight SimpleNamespace stay valid, and
  814. # historic ORM rows loaded without these columns can't AttributeError
  815. # on read.
  816. layer_usage_raw = getattr(tracking, "layer_usage", None) or {}
  817. filament_properties = getattr(tracking, "filament_properties", None) or {}
  818. printer_serial = await _get_printer_serial(printer_id)
  819. # Delete tracking row (we're done with it)
  820. await db.delete(tracking)
  821. await db.commit()
  822. if not filament_usage and not tray_remain_start:
  823. logger.debug("[SPOOLMAN] No usage data or remain-snapshot for archive %s", archive_id)
  824. return
  825. # Check if Spoolman is enabled
  826. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  827. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  828. return
  829. client = await _get_spoolman_client_with_fallback()
  830. if not client:
  831. logger.warning("[SPOOLMAN] Not reachable for usage reporting")
  832. return
  833. # Consult the live printer state for the tray-change log written by
  834. # ``bambu_mqtt.py`` on every mid-print ``tray_now`` change (#957).
  835. # When there's more than one entry, the print traversed >1 AMS tray
  836. # and the split path attributes each segment to the tray that was
  837. # loaded at the time — matches the internal Spool inventory writer
  838. # in ``usage_tracker.py``. Without this, an AMS-backup runout switch
  839. # charges the whole slot to the origin spool and pushes it past
  840. # ``initial_weight`` (#1793).
  841. #
  842. # Split only for SINGLE-slot prints — same gate as
  843. # ``usage_tracker.py:1002``. Multi-slot (multi-colour) prints
  844. # naturally cycle trays for every colour change, so splitting each
  845. # slot's grams across ALL tray_change_log entries would attribute
  846. # slot 1's grams to the segments where slot 2's tray was loaded and
  847. # vice versa. Multi-slot prints fall through to the existing
  848. # single-tray path (which uses the stable ``slot_to_tray`` mapping).
  849. nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
  850. tray_changes: list[tuple[int, int]] = []
  851. _state = None
  852. if len(nonzero_slots) == 1:
  853. from backend.app.services.printer_manager import printer_manager as _pm
  854. _state = _pm.get_status(printer_id)
  855. if _state is not None:
  856. tray_changes = list(getattr(_state, "tray_change_log", []) or [])
  857. _total_layers = int(getattr(_state, "total_layers", 0) or 0) if _state else 0
  858. _current_layer = int(getattr(_state, "layer_num", 0) or 0) if _state else 0
  859. # For the linear-fallback denominator when total_layers is 0 (P1S
  860. # firmware resets it at print end). At completion the current layer
  861. # is the print's last valid layer.
  862. _layer_denom_hint = _total_layers or _current_layer
  863. slot_colors: dict[int, str] = {}
  864. slot_materials: dict[int, str] = {}
  865. handled_global_tray_ids: set[int] = set()
  866. spools_updated = 0
  867. # --- Path 1: 3MF per-slot estimates -----------------------------
  868. if filament_usage:
  869. if len(tray_changes) > 1:
  870. # Tray-split path — attribute per-segment to the tray that
  871. # was loaded at that time.
  872. logger.info(
  873. "[SPOOLMAN] Reporting per-filament usage for archive %s with tray-split "
  874. "(tray_change_log=%s, denom_layers=%d)",
  875. archive_id,
  876. tray_changes,
  877. _layer_denom_hint,
  878. )
  879. # ``tracking.layer_usage`` was serialized to JSON so int keys
  880. # come back as strings. Restore them for the split math.
  881. layer_usage = None
  882. if layer_usage_raw:
  883. try:
  884. layer_usage = {
  885. int(layer): {int(fid): mm for fid, mm in filaments.items()}
  886. for layer, filaments in layer_usage_raw.items()
  887. }
  888. except (TypeError, ValueError, AttributeError):
  889. # AttributeError catches ``inner.items()`` when the
  890. # inner value isn't dict-shaped (corrupt JSON row).
  891. # Missing gcode falls through to the linear-ratio
  892. # branch inside ``compute_tray_split_grams`` — still
  893. # gives a correct split, just less precise.
  894. layer_usage = None
  895. split_updated, split_handled = await _report_spool_usage_split_by_tray_changes(
  896. client,
  897. filament_usage,
  898. tray_changes,
  899. ams_trays,
  900. layer_usage,
  901. filament_properties,
  902. _total_layers,
  903. _layer_denom_hint,
  904. f"Archive {archive_id}",
  905. printer_serial,
  906. printer_id=printer_id,
  907. slot_colors_out=slot_colors,
  908. slot_materials_out=slot_materials,
  909. )
  910. spools_updated += split_updated
  911. handled_global_tray_ids |= split_handled
  912. else:
  913. logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
  914. usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
  915. spools_updated = await _report_spool_usage_for_slots(
  916. client,
  917. usage_items,
  918. ams_trays,
  919. slot_to_tray,
  920. f"Archive {archive_id}",
  921. printer_serial,
  922. printer_id=printer_id,
  923. slot_colors_out=slot_colors,
  924. slot_materials_out=slot_materials,
  925. )
  926. # Track which physical slots the 3MF path already covered so
  927. # Path 2 doesn't double-charge them.
  928. for u in filament_usage:
  929. slot_id = u.get("slot_id", 0)
  930. handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays))
  931. # --- Path 2: AMS remain%-delta for slots 3MF didn't cover -------
  932. # Triggered for no-3MF "Untitled" prints (#1820) AND for partial
  933. # 3MF coverage (slots whose filament_id wasn't in slice_info).
  934. if tray_remain_start:
  935. from backend.app.services.printer_manager import printer_manager
  936. current = printer_manager.get_status(printer_id)
  937. current_lookup = _snapshot_tray_remain(current.raw_data) if current and current.raw_data else {}
  938. fallback_updates = await _report_remain_delta_for_slots(
  939. client,
  940. printer_id=printer_id,
  941. tray_remain_start=tray_remain_start,
  942. current_lookup=current_lookup,
  943. handled_global_tray_ids=handled_global_tray_ids,
  944. archive_id=archive_id,
  945. slot_colors_out=slot_colors,
  946. slot_materials_out=slot_materials,
  947. )
  948. spools_updated += fallback_updates
  949. if spools_updated == 0:
  950. logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
  951. else:
  952. logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)
  953. # Stamp the archive's filament colour from the matched Spoolman spools
  954. # so it reflects the curated inventory colour, not the slicer's 3MF
  955. # value (#1494) — mirrors the built-in inventory path in usage_tracker.
  956. await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
  957. # Same for the material: a slot mapped to a differently-typed spool than
  958. # it was sliced for otherwise records the sliced type (#2563).
  959. await _apply_spool_types_to_archive(db, archive_id, filament_usage, slot_materials)
  960. async def _report_remain_delta_for_slots(
  961. client,
  962. *,
  963. printer_id: int,
  964. tray_remain_start: dict[str, dict],
  965. current_lookup: dict[str, dict],
  966. handled_global_tray_ids: set[int],
  967. archive_id: int,
  968. slot_colors_out: dict[int, str] | None = None,
  969. slot_materials_out: dict[int, str] | None = None,
  970. ) -> int:
  971. """AMS remain%-delta path: write ``(start - current) * filament.weight``
  972. grams to Spoolman for slots the 3MF path didn't cover.
  973. Mirrors ``usage_tracker.on_print_complete`` Path 2: per-slot, gated on a
  974. valid current ``remain%``, skipped on spool swap (``tray_uuid`` changed),
  975. using the resolved spool's filament reference weight rather than MQTT's
  976. unreliable ``tray_weight`` (which is the failure mode #1119 documented).
  977. """
  978. spools_updated = 0
  979. for slot_key, start in tray_remain_start.items():
  980. try:
  981. ams_id_str, tray_id_str = slot_key.split("-", 1)
  982. ams_id, tray_id = int(ams_id_str), int(tray_id_str)
  983. except (ValueError, AttributeError):
  984. continue
  985. # Skip slots already handled by the 3MF path. Encoding mirrors
  986. # build_ams_tray_lookup: VT trays land at 254/255, AMS-HT keeps
  987. # its native id (>=128), regular AMS slots are ams_id*4+tray_id.
  988. if ams_id == 255:
  989. global_tray_id = 254 + tray_id
  990. elif ams_id >= 128:
  991. global_tray_id = ams_id
  992. else:
  993. global_tray_id = ams_id * 4 + tray_id
  994. if global_tray_id in handled_global_tray_ids:
  995. continue
  996. current = current_lookup.get(slot_key)
  997. if not current:
  998. logger.debug("[SPOOLMAN] AMS%d-T%d: no current remain%% at completion, skipping fallback", ams_id, tray_id)
  999. continue
  1000. # Spool swap mid-print — tray_uuid changed. We don't know how much
  1001. # of the print went to which spool; skip rather than mis-attribute.
  1002. start_uuid = (start.get("tray_uuid") or "").lower()
  1003. cur_uuid = (current.get("tray_uuid") or "").lower()
  1004. if start_uuid and cur_uuid and start_uuid != cur_uuid:
  1005. logger.info(
  1006. "[SPOOLMAN] AMS%d-T%d: spool swapped mid-print (uuid changed), skipping remain-delta", ams_id, tray_id
  1007. )
  1008. continue
  1009. delta_pct = start["remain"] - current["remain"]
  1010. if delta_pct <= 0:
  1011. continue # No consumption captured at AMS granularity, or refilled
  1012. spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
  1013. if spool_id is None:
  1014. logger.debug("[SPOOLMAN] AMS%d-T%d: no Spoolman slot assignment, skipping fallback", ams_id, tray_id)
  1015. continue
  1016. # Look up the spool's filament reference weight. Use a fresh GET so
  1017. # we don't depend on a stale cached_spools list. Failure here is
  1018. # silent-skip rather than fatal — other slots can still be written.
  1019. try:
  1020. spool = await client.get_spool(spool_id)
  1021. except Exception as exc: # noqa: BLE001
  1022. logger.debug("[SPOOLMAN] AMS%d-T%d: get_spool(%s) failed: %s", ams_id, tray_id, spool_id, exc)
  1023. continue
  1024. filament = spool.get("filament") or {}
  1025. ref_weight = filament.get("weight")
  1026. if not ref_weight or ref_weight <= 0:
  1027. logger.debug(
  1028. "[SPOOLMAN] AMS%d-T%d: spool %s has no filament.weight, skipping remain-delta",
  1029. ams_id,
  1030. tray_id,
  1031. spool_id,
  1032. )
  1033. continue
  1034. grams_used = round((delta_pct / 100.0) * ref_weight, 2)
  1035. if grams_used <= 0:
  1036. continue
  1037. try:
  1038. await client.use_spool(spool_id, grams_used)
  1039. except Exception as exc: # noqa: BLE001
  1040. logger.warning(
  1041. "[SPOOLMAN] AMS%d-T%d: use_spool(%s, %.2fg) failed: %s", ams_id, tray_id, spool_id, grams_used, exc
  1042. )
  1043. continue
  1044. spools_updated += 1
  1045. # No 3MF slot_id for this path — use the AMS slot key so the maps can
  1046. # still be inspected by callers if needed. The archive rewrites
  1047. # (#1494 colour, #2563 type) key on 3MF slot_ids, so remain-delta-only
  1048. # prints intentionally don't participate (matches usage_tracker's
  1049. # slot_id=None).
  1050. if slot_colors_out is not None:
  1051. color = filament.get("color_hex")
  1052. if color:
  1053. slot_colors_out[-(global_tray_id + 1)] = color
  1054. if slot_materials_out is not None:
  1055. material = filament.get("material")
  1056. if material:
  1057. slot_materials_out[-(global_tray_id + 1)] = material
  1058. logger.info(
  1059. "[SPOOLMAN] Archive %s AMS%d-T%d: %.2fg via remain-delta (%d%% of %.0fg) -> spool %s",
  1060. archive_id,
  1061. ams_id,
  1062. tray_id,
  1063. grams_used,
  1064. delta_pct,
  1065. ref_weight,
  1066. spool_id,
  1067. )
  1068. return spools_updated
  1069. async def _apply_spool_colors_to_archive(
  1070. db,
  1071. archive_id: int,
  1072. filament_usage: list[dict],
  1073. slot_colors: dict[int, str],
  1074. ) -> None:
  1075. """Overwrite an archive's ``filament_color`` with the colours of the
  1076. Spoolman spools that fed the print (#1494).
  1077. All-or-nothing, exactly like the built-in inventory path: the colour is
  1078. only rewritten when every used slot resolved to a spool that carries a
  1079. colour, so a partial match never drops slots from the archive.
  1080. """
  1081. if not slot_colors:
  1082. return
  1083. from backend.app.models.archive import PrintArchive
  1084. from backend.app.services.usage_tracker import (
  1085. _archive_colors_from_spools,
  1086. _spool_color_to_hex,
  1087. )
  1088. results = [{"slot_id": sid, "color": _spool_color_to_hex(hex_)} for sid, hex_ in slot_colors.items()]
  1089. colors = _archive_colors_from_spools(filament_usage, results)
  1090. if not colors:
  1091. return
  1092. archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
  1093. if archive is None:
  1094. return
  1095. joined = ",".join(colors)
  1096. if joined != archive.filament_color:
  1097. logger.info(
  1098. "[SPOOLMAN] Archive %s filament_color %r -> %r (from Spoolman spools)",
  1099. archive_id,
  1100. archive.filament_color,
  1101. joined,
  1102. )
  1103. archive.filament_color = joined
  1104. await db.commit()
  1105. async def _apply_spool_types_to_archive(
  1106. db,
  1107. archive_id: int,
  1108. filament_usage: list[dict],
  1109. slot_materials: dict[int, str],
  1110. ) -> None:
  1111. """Overwrite an archive's ``filament_type`` with the materials of the
  1112. Spoolman spools that fed the print (#2563).
  1113. All-or-nothing, exactly like the colour path and the built-in inventory
  1114. path: the type is only rewritten when every used slot resolved to a spool
  1115. that carries a material, so a partial match never drops slots from the
  1116. archive or the material statistics.
  1117. """
  1118. if not slot_materials:
  1119. return
  1120. from backend.app.models.archive import PrintArchive
  1121. from backend.app.services.usage_tracker import _archive_types_from_spools
  1122. results = [{"slot_id": sid, "material": material} for sid, material in slot_materials.items()]
  1123. types = _archive_types_from_spools(filament_usage, results)
  1124. if not types:
  1125. return
  1126. archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
  1127. if archive is None:
  1128. return
  1129. joined = ",".join(types)
  1130. if joined != archive.filament_type:
  1131. logger.info(
  1132. "[SPOOLMAN] Archive %s filament_type %r -> %r (from Spoolman spools)",
  1133. archive_id,
  1134. archive.filament_type,
  1135. joined,
  1136. )
  1137. archive.filament_type = joined
  1138. await db.commit()