spoolman_tracking.py 27 KB

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