spoolman_tracking.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  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 get_spoolman_client, init_spoolman_client
  12. logger = logging.getLogger(__name__)
  13. # Zero UUID used by Bambu printers for empty/unset tray_uuid
  14. _ZERO_UUID = "00000000000000000000000000000000"
  15. def _resolve_spool_tag(tray_info: dict) -> str:
  16. """Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).
  17. Returns empty string if no usable identifier is found.
  18. """
  19. tray_uuid = tray_info.get("tray_uuid", "")
  20. tag_uid = tray_info.get("tag_uid", "")
  21. if tray_uuid and tray_uuid != _ZERO_UUID:
  22. return tray_uuid
  23. return tag_uid
  24. def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None) -> int:
  25. """Map a 1-based slot_id to a global_tray_id using optional custom mapping.
  26. Default mapping: slot 1 -> tray 0, slot 2 -> tray 1, etc.
  27. Custom mapping (from print queue): slot_to_tray[slot_id - 1] overrides default.
  28. A value of -1 in custom mapping means unmapped (uses default).
  29. """
  30. global_tray_id = slot_id - 1
  31. if slot_to_tray and slot_id <= len(slot_to_tray):
  32. mapped_tray = slot_to_tray[slot_id - 1]
  33. if mapped_tray >= 0:
  34. global_tray_id = mapped_tray
  35. return global_tray_id
  36. def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
  37. """Build lookup of global_tray_id -> tray info from printer state.
  38. Returns: {0: {"tray_uuid": "...", "tag_uid": "...", "tray_type": "..."}, ...}
  39. """
  40. lookup = {}
  41. ams_data = raw_data.get("ams", [])
  42. for ams_unit in ams_data:
  43. ams_id = ams_unit.get("id", 0)
  44. for tray in ams_unit.get("tray", []):
  45. tray_id = tray.get("id", 0)
  46. # AMS-HT units have IDs starting at 128 with a single tray
  47. global_tray_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id
  48. lookup[global_tray_id] = {
  49. "tray_uuid": tray.get("tray_uuid", ""),
  50. "tag_uid": tray.get("tag_uid", ""),
  51. "tray_type": tray.get("tray_type", ""),
  52. }
  53. # External spool(s) (vt_tray is a list, global_tray_id from each entry's "id")
  54. for vt in raw_data.get("vt_tray") or []:
  55. if vt.get("tray_type"):
  56. tray_id = int(vt.get("id", 254))
  57. lookup[tray_id] = {
  58. "tray_uuid": vt.get("tray_uuid", ""),
  59. "tag_uid": vt.get("tag_uid", ""),
  60. "tray_type": vt.get("tray_type", ""),
  61. }
  62. return lookup
  63. async def store_print_data(printer_id: int, archive_id: int, file_path: str, db, printer_manager):
  64. """Store Spoolman tracking data at print start (persisted to database).
  65. Only stores data when Spoolman is enabled and AMS weight sync is disabled
  66. (i.e., we're using per-usage tracking instead of AMS percentage estimates).
  67. """
  68. from backend.app.api.routes.settings import get_setting
  69. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  70. from backend.app.models.print_queue import PrintQueueItem
  71. from backend.app.utils.threemf_tools import (
  72. extract_filament_properties_from_3mf,
  73. extract_filament_usage_from_3mf,
  74. extract_layer_filament_usage_from_3mf,
  75. )
  76. # Check if Spoolman is enabled
  77. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  78. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  79. return
  80. # Only store tracking data if "Disable AMS Weight Sync" is enabled
  81. disable_weight_sync_str = await get_setting(db, "spoolman_disable_weight_sync")
  82. disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == "true"
  83. if not disable_weight_sync:
  84. logger.debug("[SPOOLMAN] Weight sync enabled, skipping per-usage tracking data storage")
  85. return
  86. # Get 3MF file path
  87. full_path = app_settings.base_dir / file_path
  88. if not full_path.exists():
  89. logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
  90. return
  91. # Extract per-filament usage from 3MF (total usage per slot)
  92. filament_usage = extract_filament_usage_from_3mf(full_path)
  93. if not filament_usage:
  94. logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
  95. return
  96. # Get current AMS tray state
  97. state = printer_manager.get_status(printer_id)
  98. ams_trays = {}
  99. if state and state.raw_data:
  100. ams_trays = build_ams_tray_lookup(state.raw_data)
  101. # Get custom slot-to-tray mapping from queue item (if this is a queued print)
  102. slot_to_tray = None
  103. queue_result = await db.execute(
  104. select(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id).where(PrintQueueItem.status == "printing")
  105. )
  106. queue_item = queue_result.scalar_one_or_none()
  107. if queue_item and queue_item.ams_mapping:
  108. try:
  109. slot_to_tray = json.loads(queue_item.ams_mapping)
  110. except json.JSONDecodeError:
  111. pass # Ignore malformed AMS mapping; fall back to default slot assignment
  112. # Parse G-code for per-layer filament usage (for accurate partial usage tracking)
  113. layer_usage = extract_layer_filament_usage_from_3mf(full_path)
  114. layer_usage_json = None
  115. if layer_usage:
  116. # Convert int keys to string for JSON serialization
  117. layer_usage_json = {str(k): v for k, v in layer_usage.items()}
  118. logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
  119. # Extract filament properties (density, diameter) for mm -> grams conversion
  120. filament_properties = extract_filament_properties_from_3mf(full_path)
  121. # Delete any existing row for this printer/archive (shouldn't exist, but just in case)
  122. await db.execute(
  123. delete(ActivePrintSpoolman)
  124. .where(ActivePrintSpoolman.printer_id == printer_id)
  125. .where(ActivePrintSpoolman.archive_id == archive_id)
  126. )
  127. # Insert new tracking data
  128. tracking = ActivePrintSpoolman(
  129. printer_id=printer_id,
  130. archive_id=archive_id,
  131. filament_usage=filament_usage,
  132. ams_trays=ams_trays,
  133. slot_to_tray=slot_to_tray,
  134. layer_usage=layer_usage_json,
  135. filament_properties=filament_properties,
  136. )
  137. db.add(tracking)
  138. await db.commit()
  139. logger.info("[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s", printer_id, archive_id)
  140. logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
  141. logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
  142. if slot_to_tray:
  143. logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
  144. if layer_usage_json:
  145. logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
  146. async def cleanup_tracking(printer_id: int, archive_id: int, db):
  147. """Report partial usage and clean up Spoolman tracking data for failed/aborted prints."""
  148. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  149. # Get tracking data first (needed for partial usage reporting)
  150. result = await db.execute(
  151. select(ActivePrintSpoolman)
  152. .where(ActivePrintSpoolman.printer_id == printer_id)
  153. .where(ActivePrintSpoolman.archive_id == archive_id)
  154. )
  155. tracking = result.scalar_one_or_none()
  156. if not tracking:
  157. logger.debug("[SPOOLMAN] No tracking data to clean up for printer=%s, archive=%s", printer_id, archive_id)
  158. return
  159. # Try to report partial usage before cleanup
  160. try:
  161. await _report_partial_usage(printer_id, tracking)
  162. except Exception as e:
  163. logger.warning("[SPOOLMAN] Partial usage report failed: %s", e)
  164. # Delete tracking data
  165. await db.execute(
  166. delete(ActivePrintSpoolman)
  167. .where(ActivePrintSpoolman.printer_id == printer_id)
  168. .where(ActivePrintSpoolman.archive_id == archive_id)
  169. )
  170. await db.commit()
  171. logger.debug("[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s", printer_id, archive_id)
  172. async def _get_spoolman_client_with_fallback():
  173. """Get Spoolman client, initializing from settings if needed.
  174. Returns (client, is_healthy) tuple. Client may be None.
  175. """
  176. client = await get_spoolman_client()
  177. if not client:
  178. async with async_session() as db:
  179. from backend.app.api.routes.settings import get_setting
  180. spoolman_url = await get_setting(db, "spoolman_url")
  181. if spoolman_url:
  182. client = await init_spoolman_client(spoolman_url)
  183. if not client or not await client.health_check():
  184. return None
  185. return client
  186. async def _report_spool_usage_for_slots(
  187. client,
  188. filament_usage_items: list[tuple[int, float]],
  189. ams_trays: dict[int, dict],
  190. slot_to_tray: list | None,
  191. method_label: str,
  192. ) -> int:
  193. """Report usage to Spoolman for a list of (slot_id, grams) pairs.
  194. Returns number of spools successfully updated.
  195. """
  196. spools_updated = 0
  197. for slot_id, grams_used in filament_usage_items:
  198. if grams_used <= 0:
  199. continue
  200. global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray)
  201. tray_info = ams_trays.get(global_tray_id)
  202. if not tray_info:
  203. logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
  204. continue
  205. spool_tag = _resolve_spool_tag(tray_info)
  206. if not spool_tag:
  207. logger.debug("[SPOOLMAN] Slot %s: no identifier for tray %s", slot_id, global_tray_id)
  208. continue
  209. spool = await client.find_spool_by_tag(spool_tag)
  210. if not spool:
  211. logger.debug("[SPOOLMAN] Slot %s: no spool for tag %s...", slot_id, spool_tag[:16])
  212. continue
  213. result = await client.use_spool(spool["id"], grams_used)
  214. if result:
  215. logger.info("[SPOOLMAN] %s: slot %s: %sg -> spool %s", method_label, slot_id, grams_used, spool["id"])
  216. spools_updated += 1
  217. return spools_updated
  218. async def _report_partial_usage(printer_id: int, tracking):
  219. """Report partial filament usage based on actual G-code layer data.
  220. Uses per-layer cumulative extrusion from G-code parsing for accurate
  221. multi-material tracking. Falls back to linear interpolation if G-code
  222. data is unavailable.
  223. """
  224. from backend.app.services.printer_manager import printer_manager
  225. from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer, mm_to_grams
  226. async with async_session() as db:
  227. from backend.app.api.routes.settings import get_setting
  228. # Check if partial usage reporting is enabled (default: true)
  229. report_partial = await get_setting(db, "spoolman_report_partial_usage")
  230. if report_partial and report_partial.lower() == "false":
  231. logger.debug("[SPOOLMAN] Partial usage reporting disabled by setting")
  232. return
  233. # Check if Spoolman is enabled
  234. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  235. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  236. return
  237. # Get current printer state for layer progress
  238. state = printer_manager.get_status(printer_id)
  239. if not state:
  240. logger.debug("[SPOOLMAN] No printer state available for partial usage")
  241. return
  242. current_layer = state.layer_num
  243. total_layers = state.total_layers
  244. if not current_layer or current_layer <= 0:
  245. logger.debug("[SPOOLMAN] No progress to report (layer 0 or unknown)")
  246. return
  247. logger.info("[SPOOLMAN] Reporting partial usage at layer %s/%s", current_layer, total_layers or "?")
  248. # Get tracking data
  249. layer_usage = tracking.layer_usage
  250. filament_properties = tracking.filament_properties or {}
  251. filament_usage = tracking.filament_usage or []
  252. ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
  253. slot_to_tray = tracking.slot_to_tray
  254. client = await _get_spoolman_client_with_fallback()
  255. if not client:
  256. logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
  257. return
  258. # Try to use accurate G-code parsed data
  259. if layer_usage:
  260. layer_usage_int = {
  261. int(layer): {int(fid): mm for fid, mm in filaments.items()} for layer, filaments in layer_usage.items()
  262. }
  263. usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
  264. if usage_mm:
  265. logger.info("[SPOOLMAN] Using G-code parsed data for layer %s", current_layer)
  266. # Build (slot_id, grams) list using Spoolman densities with 3MF fallback
  267. usage_items = []
  268. for filament_id, mm_used in usage_mm.items():
  269. slot_id = filament_id + 1 # filament_id is 0-based, slot_id is 1-based
  270. # Get density from Spoolman (most accurate), fall back to 3MF, then PLA default
  271. global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray)
  272. tray_info = ams_trays.get(global_tray_id)
  273. density = None
  274. diameter = 1.75
  275. if tray_info:
  276. spool_tag = _resolve_spool_tag(tray_info)
  277. if spool_tag:
  278. spool = await client.find_spool_by_tag(spool_tag)
  279. if spool:
  280. filament_data = spool.get("filament", {})
  281. density = filament_data.get("density")
  282. diameter = filament_data.get("diameter", 1.75)
  283. if not density:
  284. props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))
  285. density = props.get("density", 1.24)
  286. logger.debug("[SPOOLMAN] Using fallback density %s for slot %s", density, slot_id)
  287. grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
  288. usage_items.append((slot_id, grams_used))
  289. spools_updated = await _report_spool_usage_for_slots(
  290. client, usage_items, ams_trays, slot_to_tray, "Partial (G-code)"
  291. )
  292. if spools_updated > 0:
  293. logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
  294. return
  295. # Fallback: linear interpolation (if no G-code data available)
  296. if not total_layers or total_layers <= 0:
  297. logger.debug("[SPOOLMAN] Cannot use linear fallback: total_layers=%s", total_layers)
  298. return
  299. progress_ratio = min(current_layer / total_layers, 1.0)
  300. logger.info("[SPOOLMAN] Falling back to linear interpolation (%s)", progress_ratio)
  301. usage_items = []
  302. for usage in filament_usage:
  303. slot_id = usage.get("slot_id", 0)
  304. total_used_g = usage.get("used_g", 0)
  305. if total_used_g > 0:
  306. partial_used_g = round(total_used_g * progress_ratio, 2)
  307. usage_items.append((slot_id, partial_used_g))
  308. spools_updated = await _report_spool_usage_for_slots(
  309. client, usage_items, ams_trays, slot_to_tray, "Partial (linear)"
  310. )
  311. if spools_updated > 0:
  312. logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
  313. async def report_usage(printer_id: int, archive_id: int):
  314. """Report filament usage to Spoolman after print completion.
  315. Uses per-filament usage data captured at print start to report
  316. usage to the correct spools.
  317. """
  318. async with async_session() as db:
  319. from backend.app.api.routes.settings import get_setting
  320. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  321. # Get tracking data stored at print start
  322. result = await db.execute(
  323. select(ActivePrintSpoolman)
  324. .where(ActivePrintSpoolman.printer_id == printer_id)
  325. .where(ActivePrintSpoolman.archive_id == archive_id)
  326. )
  327. tracking = result.scalar_one_or_none()
  328. if not tracking:
  329. logger.info("[SPOOLMAN] No tracking data for print (printer=%s, archive=%s)", printer_id, archive_id)
  330. return
  331. filament_usage = tracking.filament_usage or []
  332. ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
  333. slot_to_tray = tracking.slot_to_tray
  334. # Delete tracking row (we're done with it)
  335. await db.delete(tracking)
  336. await db.commit()
  337. if not filament_usage:
  338. logger.debug("[SPOOLMAN] No filament usage data for archive %s", archive_id)
  339. return
  340. # Check if Spoolman is enabled
  341. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  342. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  343. return
  344. client = await _get_spoolman_client_with_fallback()
  345. if not client:
  346. logger.warning("[SPOOLMAN] Not reachable for usage reporting")
  347. return
  348. logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
  349. usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
  350. spools_updated = await _report_spool_usage_for_slots(
  351. client, usage_items, ams_trays, slot_to_tray, f"Archive {archive_id}"
  352. )
  353. if spools_updated == 0:
  354. logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
  355. else:
  356. logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)