spoolman_tracking.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  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. async def store_print_data(
  149. printer_id: int,
  150. archive_id: int,
  151. file_path: str,
  152. db,
  153. printer_manager,
  154. ams_mapping: list[int] | None = None,
  155. plate_id: int | None = None,
  156. ):
  157. """Store Spoolman tracking data at print start (persisted to database).
  158. Per-print tracking is the primary weight-update path for Spoolman, mirroring
  159. how the internal Filament Inventory works. The legacy AMS-remain%-based sync
  160. is no longer used as a weight writer (#1119), so this runs whenever Spoolman
  161. is enabled regardless of the deprecated `spoolman_disable_weight_sync` flag.
  162. ``plate_id``, when set, scopes the 3MF filament extract to a single plate so
  163. queue / direct-Print dispatch of plate N of a multi-plate file doesn't
  164. attribute every plate's filament to the printed spool (#1697). When unset,
  165. the queue item's plate_id (if any) is used; otherwise the whole-file sum is
  166. extracted, which is correct for direct prints that target the first/only
  167. plate of a single-plate file.
  168. """
  169. from backend.app.api.routes.settings import get_setting
  170. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  171. from backend.app.models.print_queue import PrintQueueItem
  172. from backend.app.utils.threemf_tools import (
  173. extract_filament_properties_from_3mf,
  174. extract_filament_usage_from_3mf,
  175. extract_layer_filament_usage_from_3mf,
  176. )
  177. # Check if Spoolman is enabled
  178. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  179. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  180. return
  181. # Get 3MF file path
  182. full_path = (
  183. app_settings.base_dir / file_path
  184. ) # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated
  185. if not full_path.exists():
  186. logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
  187. return
  188. # Resolve the queue item once — used both for the plate-scoped 3MF parsing
  189. # fallback (#1697: multi-plate file dispatched for one plate must only count
  190. # that plate's filament) and for the ams_mapping fallback below.
  191. queue_result = await db.execute(
  192. select(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id).where(PrintQueueItem.status == "printing")
  193. )
  194. queue_item = queue_result.scalar_one_or_none()
  195. # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
  196. # item's plate_id (queue dispatch path).
  197. effective_plate_id = plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
  198. # Extract per-filament usage from 3MF (total usage for the dispatched plate,
  199. # or the whole file for direct/library prints with no plate_id).
  200. filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id)
  201. if not filament_usage:
  202. logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
  203. return
  204. # Get current AMS tray state
  205. state = printer_manager.get_status(printer_id)
  206. ams_trays = {}
  207. if state and state.raw_data:
  208. ams_trays = build_ams_tray_lookup(state.raw_data)
  209. # Prefer the explicit mapping captured from the print command, then fall back
  210. # to any queue mapping stored for scheduled/reprint jobs.
  211. slot_to_tray = ams_mapping if ams_mapping is not None else None
  212. if not slot_to_tray and queue_item and queue_item.ams_mapping:
  213. try:
  214. slot_to_tray = json.loads(queue_item.ams_mapping)
  215. except json.JSONDecodeError:
  216. pass # Ignore malformed AMS mapping; fall back to default slot assignment
  217. # Parse G-code for per-layer filament usage (for accurate partial usage tracking)
  218. layer_usage = extract_layer_filament_usage_from_3mf(full_path)
  219. layer_usage_json = None
  220. if layer_usage:
  221. # Convert int keys to string for JSON serialization
  222. layer_usage_json = {str(k): v for k, v in layer_usage.items()}
  223. logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
  224. # Extract filament properties (density, diameter) for mm -> grams conversion
  225. filament_properties = extract_filament_properties_from_3mf(full_path)
  226. # Delete any existing row for this printer/archive (shouldn't exist, but just in case)
  227. await db.execute(
  228. delete(ActivePrintSpoolman)
  229. .where(ActivePrintSpoolman.printer_id == printer_id)
  230. .where(ActivePrintSpoolman.archive_id == archive_id)
  231. )
  232. # Insert new tracking data
  233. tracking = ActivePrintSpoolman(
  234. printer_id=printer_id,
  235. archive_id=archive_id,
  236. filament_usage=filament_usage,
  237. ams_trays=ams_trays,
  238. slot_to_tray=slot_to_tray,
  239. layer_usage=layer_usage_json,
  240. filament_properties=filament_properties,
  241. )
  242. db.add(tracking)
  243. await db.commit()
  244. logger.info("[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s", printer_id, archive_id)
  245. logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
  246. logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
  247. if slot_to_tray:
  248. logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
  249. if layer_usage_json:
  250. logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
  251. async def cleanup_tracking(
  252. printer_id: int,
  253. archive_id: int,
  254. db,
  255. last_layer_num: int | None = None,
  256. last_progress: int | None = None,
  257. ):
  258. """Report partial usage and clean up Spoolman tracking data for failed/aborted prints."""
  259. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  260. # Get tracking data first (needed for partial usage reporting)
  261. result = await db.execute(
  262. select(ActivePrintSpoolman)
  263. .where(ActivePrintSpoolman.printer_id == printer_id)
  264. .where(ActivePrintSpoolman.archive_id == archive_id)
  265. )
  266. tracking = result.scalar_one_or_none()
  267. if not tracking:
  268. logger.debug("[SPOOLMAN] No tracking data to clean up for printer=%s, archive=%s", printer_id, archive_id)
  269. return
  270. # Try to report partial usage before cleanup
  271. try:
  272. await _report_partial_usage(
  273. printer_id,
  274. tracking,
  275. last_layer_num=last_layer_num,
  276. last_progress=last_progress,
  277. )
  278. except Exception as e:
  279. logger.warning("[SPOOLMAN] Partial usage report failed: %s", e)
  280. # Delete tracking data
  281. await db.execute(
  282. delete(ActivePrintSpoolman)
  283. .where(ActivePrintSpoolman.printer_id == printer_id)
  284. .where(ActivePrintSpoolman.archive_id == archive_id)
  285. )
  286. await db.commit()
  287. logger.debug("[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s", printer_id, archive_id)
  288. async def _get_spoolman_client_with_fallback():
  289. """Get Spoolman client, initializing from settings if needed.
  290. Returns (client, is_healthy) tuple. Client may be None.
  291. """
  292. client = await get_spoolman_client()
  293. if not client:
  294. async with async_session() as db:
  295. from backend.app.api.routes.settings import get_setting
  296. spoolman_url = await get_setting(db, "spoolman_url")
  297. if spoolman_url:
  298. try:
  299. client = await init_spoolman_client(spoolman_url)
  300. except ValueError as exc:
  301. logger.warning("Spoolman URL %r rejected by SSRF guard: %s", spoolman_url, exc)
  302. return None
  303. if not client:
  304. return None
  305. if not await client.health_check():
  306. logger.warning("Spoolman health check failed; skipping usage reporting")
  307. return None
  308. return client
  309. async def _resolve_spool_id_via_slot_assignment(printer_id: int, ams_id: int, tray_id: int) -> int | None:
  310. """Look up the Spoolman spool ID locally bound to (printer, ams, tray).
  311. Fallback path for #1459: when a tag-less spool was assigned via the
  312. Bambuddy UI, the user's deterministic fallback tag is intentionally NOT
  313. written to Spoolman's extra.tag (kept clean per #1457), so
  314. find_spool_by_tag misses. The local spoolman_slot_assignments table is
  315. the authoritative binding for those spools.
  316. """
  317. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  318. async with async_session() as db:
  319. result = await db.execute(
  320. select(SpoolmanSlotAssignment.spoolman_spool_id).where(
  321. SpoolmanSlotAssignment.printer_id == printer_id,
  322. SpoolmanSlotAssignment.ams_id == ams_id,
  323. SpoolmanSlotAssignment.tray_id == tray_id,
  324. )
  325. )
  326. return result.scalar_one_or_none()
  327. async def _report_spool_usage_for_slots(
  328. client,
  329. filament_usage_items: list[tuple[int, float]],
  330. ams_trays: dict[int, dict],
  331. slot_to_tray: list | None,
  332. method_label: str,
  333. printer_serial: str = "",
  334. printer_id: int | None = None,
  335. slot_colors_out: dict[int, str] | None = None,
  336. ) -> int:
  337. """Report usage to Spoolman for a list of (slot_id, grams) pairs.
  338. Resolution order per slot: (1) Spoolman extra.tag match against the
  339. tray's RFID or deterministic fallback tag, (2) #1459 fallback —
  340. local spoolman_slot_assignments table keyed by (printer_id, ams_id,
  341. tray_id). Without (2), tag-less spools assigned via the Bambuddy UI
  342. never get their weight decremented because their extra.tag is empty
  343. on the Spoolman side.
  344. When ``slot_colors_out`` is provided it is populated with
  345. ``{slot_id: color_hex}`` for every resolved spool — used by
  346. :func:`report_usage` to stamp the archive's filament colour from the
  347. Spoolman spool rather than the slicer's 3MF value (#1494).
  348. Returns number of spools successfully updated.
  349. """
  350. spools_updated = 0
  351. for slot_id, grams_used in filament_usage_items:
  352. if grams_used <= 0:
  353. continue
  354. global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
  355. tray_info = ams_trays.get(global_tray_id)
  356. if not tray_info:
  357. logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
  358. continue
  359. is_external = global_tray_id >= 254
  360. tray_type = tray_info.get("tray_type", "")
  361. logger.debug(
  362. "[SPOOLMAN] Slot %s resolved to global_tray_id %s (tray_type=%s, external=%s)",
  363. slot_id,
  364. global_tray_id,
  365. tray_type or "unknown",
  366. is_external,
  367. )
  368. spool_id_to_use: int | None = None
  369. resolution_path = ""
  370. # color_hex of the resolved spool's filament, for the #1494 archive
  371. # colour rewrite. The tag path already has the full spool object;
  372. # the slot-assignment path only yields an id and is fetched below.
  373. spool_color_hex: str | None = None
  374. spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
  375. if spool_tag:
  376. spool = await client.find_spool_by_tag(spool_tag)
  377. if spool:
  378. spool_id_to_use = spool["id"]
  379. resolution_path = "tag"
  380. spool_color_hex = (spool.get("filament") or {}).get("color_hex")
  381. if spool_id_to_use is None and printer_id is not None:
  382. ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
  383. spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
  384. if spool_id_to_use is not None:
  385. resolution_path = "slot-assignment"
  386. if spool_id_to_use is None:
  387. logger.debug(
  388. "[SPOOLMAN] Slot %s: no spool resolved (tag=%s, no slot-assignment)",
  389. slot_id,
  390. spool_tag[:16] if spool_tag else "none",
  391. )
  392. continue
  393. # Record the spool's filament colour for the archive rewrite (#1494).
  394. # The slot-assignment path resolved only an id, so fetch the spool.
  395. # Strictly best-effort: a colour-fetch failure must never abort the
  396. # weight reporting for the remaining slots, so the catch is broad.
  397. if slot_colors_out is not None:
  398. if spool_color_hex is None:
  399. try:
  400. full_spool = await client.get_spool(spool_id_to_use)
  401. spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
  402. except Exception as exc: # noqa: BLE001 — colour is non-critical
  403. logger.debug("[SPOOLMAN] Slot %s: could not fetch spool colour: %s", slot_id, exc)
  404. if spool_color_hex:
  405. slot_colors_out[slot_id] = spool_color_hex
  406. try:
  407. await client.use_spool(spool_id_to_use, grams_used)
  408. logger.info(
  409. "[SPOOLMAN] %s: slot %s: %sg -> spool %s (via %s)",
  410. method_label,
  411. slot_id,
  412. grams_used,
  413. spool_id_to_use,
  414. resolution_path,
  415. )
  416. spools_updated += 1
  417. except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
  418. logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool_id_to_use, exc)
  419. return spools_updated
  420. async def _report_partial_usage(
  421. printer_id: int,
  422. tracking,
  423. last_layer_num: int | None = None,
  424. last_progress: int | None = None,
  425. ):
  426. """Report partial filament usage based on actual G-code layer data.
  427. Uses per-layer cumulative extrusion from G-code parsing for accurate
  428. multi-material tracking. Falls back to linear interpolation if G-code
  429. data is unavailable.
  430. """
  431. from backend.app.services.printer_manager import printer_manager
  432. from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer, mm_to_grams
  433. async with async_session() as db:
  434. from backend.app.api.routes.settings import get_setting
  435. # Check if partial usage reporting is enabled (default: true)
  436. report_partial = await get_setting(db, "spoolman_report_partial_usage")
  437. if report_partial and report_partial.lower() == "false":
  438. logger.debug("[SPOOLMAN] Partial usage reporting disabled by setting")
  439. return
  440. # Check if Spoolman is enabled
  441. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  442. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  443. return
  444. # Get current printer state for layer progress.
  445. # On failed/aborted prints the firmware may already reset to IDLE with layer=0,
  446. # so we fall back to completion-time hints captured from MQTT.
  447. state = printer_manager.get_status(printer_id)
  448. current_layer = state.layer_num if state else None
  449. total_layers = state.total_layers if state else None
  450. if (not current_layer or current_layer <= 0) and last_layer_num and last_layer_num > 0:
  451. current_layer = last_layer_num
  452. logger.debug("[SPOOLMAN] Using captured last_layer_num=%s for partial usage", current_layer)
  453. progress_ratio_from_event = None
  454. if last_progress is not None:
  455. try:
  456. progress_ratio_from_event = min(max(float(last_progress), 0.0), 100.0) / 100.0
  457. except (TypeError, ValueError):
  458. progress_ratio_from_event = None
  459. if (not current_layer or current_layer <= 0) and progress_ratio_from_event and total_layers and total_layers > 0:
  460. current_layer = max(1, int(round(total_layers * progress_ratio_from_event)))
  461. logger.debug(
  462. "[SPOOLMAN] Estimated layer from last_progress=%s%% and total_layers=%s -> %s",
  463. last_progress,
  464. total_layers,
  465. current_layer,
  466. )
  467. if not current_layer or current_layer <= 0:
  468. logger.debug(
  469. "[SPOOLMAN] No progress to report (layer 0/unknown, last_layer_num=%s, last_progress=%s)",
  470. last_layer_num,
  471. last_progress,
  472. )
  473. return
  474. logger.info("[SPOOLMAN] Reporting partial usage at layer %s/%s", current_layer, total_layers or "?")
  475. # Get tracking data
  476. layer_usage = tracking.layer_usage
  477. filament_properties = tracking.filament_properties or {}
  478. filament_usage = tracking.filament_usage or []
  479. ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
  480. slot_to_tray = tracking.slot_to_tray
  481. printer_serial = await _get_printer_serial(printer_id)
  482. client = await _get_spoolman_client_with_fallback()
  483. if not client:
  484. logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
  485. return
  486. # Try to use accurate G-code parsed data
  487. if layer_usage:
  488. layer_usage_int = {
  489. int(layer): {int(fid): mm for fid, mm in filaments.items()} for layer, filaments in layer_usage.items()
  490. }
  491. usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
  492. if usage_mm:
  493. logger.info("[SPOOLMAN] Using G-code parsed data for layer %s", current_layer)
  494. # Build (slot_id, grams) list using Spoolman densities with 3MF fallback
  495. usage_items = []
  496. for filament_id, mm_used in usage_mm.items():
  497. slot_id = filament_id + 1 # filament_id is 0-based, slot_id is 1-based
  498. # Get density from Spoolman (most accurate), fall back to 3MF, then PLA default
  499. global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
  500. tray_info = ams_trays.get(global_tray_id)
  501. density = None
  502. diameter = 1.75
  503. if tray_info:
  504. spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
  505. if spool_tag:
  506. spool = await client.find_spool_by_tag(spool_tag)
  507. if spool:
  508. filament_data = spool.get("filament", {})
  509. density = filament_data.get("density")
  510. diameter = filament_data.get("diameter", 1.75)
  511. if not density:
  512. props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))
  513. density = props.get("density", 1.24)
  514. logger.debug("[SPOOLMAN] Using fallback density %s for slot %s", density, slot_id)
  515. grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
  516. usage_items.append((slot_id, grams_used))
  517. spools_updated = await _report_spool_usage_for_slots(
  518. client,
  519. usage_items,
  520. ams_trays,
  521. slot_to_tray,
  522. "Partial (G-code)",
  523. printer_serial,
  524. printer_id=printer_id,
  525. )
  526. if spools_updated > 0:
  527. logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
  528. return
  529. # Fallback: linear interpolation (if no G-code data available)
  530. progress_ratio = None
  531. if total_layers and total_layers > 0:
  532. progress_ratio = min(current_layer / total_layers, 1.0)
  533. elif progress_ratio_from_event is not None:
  534. progress_ratio = progress_ratio_from_event
  535. if progress_ratio is None:
  536. logger.debug(
  537. "[SPOOLMAN] Cannot use linear fallback: total_layers=%s, last_progress=%s",
  538. total_layers,
  539. last_progress,
  540. )
  541. return
  542. logger.info("[SPOOLMAN] Falling back to linear interpolation (%s)", progress_ratio)
  543. usage_items = []
  544. for usage in filament_usage:
  545. slot_id = usage.get("slot_id", 0)
  546. total_used_g = usage.get("used_g", 0)
  547. if total_used_g > 0:
  548. partial_used_g = round(total_used_g * progress_ratio, 2)
  549. usage_items.append((slot_id, partial_used_g))
  550. spools_updated = await _report_spool_usage_for_slots(
  551. client,
  552. usage_items,
  553. ams_trays,
  554. slot_to_tray,
  555. "Partial (linear)",
  556. printer_serial,
  557. printer_id=printer_id,
  558. )
  559. if spools_updated > 0:
  560. logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
  561. async def report_usage(printer_id: int, archive_id: int):
  562. """Report filament usage to Spoolman after print completion.
  563. Uses per-filament usage data captured at print start to report
  564. usage to the correct spools.
  565. """
  566. async with async_session() as db:
  567. from backend.app.api.routes.settings import get_setting
  568. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  569. # Get tracking data stored at print start
  570. result = await db.execute(
  571. select(ActivePrintSpoolman)
  572. .where(ActivePrintSpoolman.printer_id == printer_id)
  573. .where(ActivePrintSpoolman.archive_id == archive_id)
  574. )
  575. tracking = result.scalar_one_or_none()
  576. if not tracking:
  577. logger.info("[SPOOLMAN] No tracking data for print (printer=%s, archive=%s)", printer_id, archive_id)
  578. return
  579. filament_usage = tracking.filament_usage or []
  580. ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
  581. slot_to_tray = tracking.slot_to_tray
  582. printer_serial = await _get_printer_serial(printer_id)
  583. # Delete tracking row (we're done with it)
  584. await db.delete(tracking)
  585. await db.commit()
  586. if not filament_usage:
  587. logger.debug("[SPOOLMAN] No filament usage data for archive %s", archive_id)
  588. return
  589. # Check if Spoolman is enabled
  590. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  591. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  592. return
  593. client = await _get_spoolman_client_with_fallback()
  594. if not client:
  595. logger.warning("[SPOOLMAN] Not reachable for usage reporting")
  596. return
  597. logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
  598. usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
  599. slot_colors: dict[int, str] = {}
  600. spools_updated = await _report_spool_usage_for_slots(
  601. client,
  602. usage_items,
  603. ams_trays,
  604. slot_to_tray,
  605. f"Archive {archive_id}",
  606. printer_serial,
  607. printer_id=printer_id,
  608. slot_colors_out=slot_colors,
  609. )
  610. if spools_updated == 0:
  611. logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
  612. else:
  613. logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)
  614. # Stamp the archive's filament colour from the matched Spoolman spools
  615. # so it reflects the curated inventory colour, not the slicer's 3MF
  616. # value (#1494) — mirrors the built-in inventory path in usage_tracker.
  617. await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
  618. async def _apply_spool_colors_to_archive(
  619. db,
  620. archive_id: int,
  621. filament_usage: list[dict],
  622. slot_colors: dict[int, str],
  623. ) -> None:
  624. """Overwrite an archive's ``filament_color`` with the colours of the
  625. Spoolman spools that fed the print (#1494).
  626. All-or-nothing, exactly like the built-in inventory path: the colour is
  627. only rewritten when every used slot resolved to a spool that carries a
  628. colour, so a partial match never drops slots from the archive.
  629. """
  630. if not slot_colors:
  631. return
  632. from backend.app.models.archive import PrintArchive
  633. from backend.app.services.usage_tracker import (
  634. _archive_colors_from_spools,
  635. _spool_color_to_hex,
  636. )
  637. results = [{"slot_id": sid, "color": _spool_color_to_hex(hex_)} for sid, hex_ in slot_colors.items()]
  638. colors = _archive_colors_from_spools(filament_usage, results)
  639. if not colors:
  640. return
  641. archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
  642. if archive is None:
  643. return
  644. joined = ",".join(colors)
  645. if joined != archive.filament_color:
  646. logger.info(
  647. "[SPOOLMAN] Archive %s filament_color %r -> %r (from Spoolman spools)",
  648. archive_id,
  649. archive.filament_color,
  650. joined,
  651. )
  652. archive.filament_color = joined
  653. await db.commit()