spoolman_tracking.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  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. _ZERO_TAG_UID = "0000000000000000"
  16. def _is_non_zero_identifier(value: str) -> bool:
  17. """Return True when identifier is non-empty and not all zeros."""
  18. if not value:
  19. return False
  20. return set(value) != {"0"}
  21. def _to_fixed_hex(value: int, width: int) -> str:
  22. """Mirror frontend toFixedHex(): uppercase, zero-padded, fixed width."""
  23. safe = max(0, int(value))
  24. return format(safe, "X").zfill(width)[-width:]
  25. def _hash_serial_to_hex32(serial: str) -> str:
  26. """Mirror frontend hashSerialToHex32() exactly (32-bit FNV-1a)."""
  27. input_str = (serial or "").strip().upper()
  28. hash_value = 0x811C9DC5
  29. for char in input_str:
  30. hash_value ^= ord(char)
  31. hash_value = (hash_value * 0x01000193) & 0xFFFFFFFF
  32. return format(hash_value, "X").zfill(8)
  33. def _global_tray_id_to_ams_slot(global_tray_id: int) -> tuple[int, int]:
  34. """Convert global tray id to (ams_id, tray_id) tuple for fallback tag generation."""
  35. # External spool slots use IDs 254/255 and map to ams_id=255 tray_id=0/1.
  36. if global_tray_id >= 254:
  37. return 255, max(0, global_tray_id - 254)
  38. # AMS-HT units are addressed by ams_id directly and have a single tray.
  39. if global_tray_id >= 128:
  40. return global_tray_id, 0
  41. # Standard AMS units: four trays each.
  42. return global_tray_id // 4, global_tray_id % 4
  43. def _get_fallback_spool_tag(printer_serial: str, global_tray_id: int) -> str:
  44. """Mirror frontend getFallbackSpoolTag(serial, amsId, trayId) exactly."""
  45. if not printer_serial:
  46. return ""
  47. ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
  48. return f"{_hash_serial_to_hex32(printer_serial)}{_to_fixed_hex(ams_id, 4)}{_to_fixed_hex(tray_id, 4)}"
  49. def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id: int | None = None) -> str:
  50. """Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).
  51. Returns empty string if no usable identifier is found.
  52. """
  53. tray_uuid = str(tray_info.get("tray_uuid", "") or "")
  54. tag_uid = str(tray_info.get("tag_uid", "") or "")
  55. if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid):
  56. return tray_uuid
  57. if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
  58. return tag_uid
  59. if global_tray_id is not None:
  60. return _get_fallback_spool_tag(printer_serial, global_tray_id)
  61. return ""
  62. async def _get_printer_serial(printer_id: int) -> str:
  63. """Get printer serial for deterministic fallback tag generation."""
  64. from backend.app.models.printer import Printer
  65. from backend.app.services.printer_manager import printer_manager
  66. printer_info = printer_manager.get_printer(printer_id)
  67. if printer_info and printer_info.serial_number:
  68. return printer_info.serial_number
  69. async with async_session() as db:
  70. result = await db.execute(select(Printer.serial_number).where(Printer.id == printer_id))
  71. serial_number = result.scalar_one_or_none()
  72. return serial_number or ""
  73. def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays: dict | None = None) -> int:
  74. """Map a 1-based slot_id to a global_tray_id using optional custom mapping.
  75. Custom mapping: slot_to_tray[slot_id - 1] is used when >= 0.
  76. Position-based default: uses sorted ams_trays keys so external spools (ID 254/255)
  77. naturally follow standard AMS trays, matching the slicer's slot numbering.
  78. A value of -1 in the custom mapping means unmapped (uses position-based default).
  79. Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools).
  80. """
  81. if slot_to_tray and slot_id <= len(slot_to_tray):
  82. mapped_tray = slot_to_tray[slot_id - 1]
  83. if mapped_tray >= 0:
  84. return mapped_tray
  85. # Position-based default: sort available tray IDs so external spools (254/255)
  86. # come after standard AMS trays, matching the slicer's slot assignment order.
  87. if ams_trays:
  88. sorted_tray_ids = sorted(ams_trays.keys())
  89. if slot_id <= len(sorted_tray_ids):
  90. return sorted_tray_ids[slot_id - 1]
  91. return slot_id - 1
  92. def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
  93. """Build lookup of global_tray_id -> tray info from printer state.
  94. Returns: {0: {"tray_uuid": "...", "tag_uid": "...", "tray_type": "..."}, ...}
  95. """
  96. lookup = {}
  97. ams_data = raw_data.get("ams", [])
  98. for ams_unit in ams_data:
  99. ams_id = int(ams_unit.get("id", 0))
  100. for tray in ams_unit.get("tray", []):
  101. tray_id = int(tray.get("id", 0))
  102. # AMS-HT units have IDs starting at 128 with a single tray
  103. global_tray_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id
  104. lookup[global_tray_id] = {
  105. "tray_uuid": tray.get("tray_uuid", ""),
  106. "tag_uid": tray.get("tag_uid", ""),
  107. "tray_type": tray.get("tray_type", ""),
  108. }
  109. # External spool(s) (vt_tray is a list, global_tray_id from each entry's "id")
  110. for vt in raw_data.get("vt_tray") or []:
  111. if vt.get("tray_type"):
  112. tray_id = int(vt.get("id", 254))
  113. lookup[tray_id] = {
  114. "tray_uuid": vt.get("tray_uuid", ""),
  115. "tag_uid": vt.get("tag_uid", ""),
  116. "tray_type": vt.get("tray_type", ""),
  117. }
  118. return lookup
  119. async def store_print_data(
  120. printer_id: int,
  121. archive_id: int,
  122. file_path: str,
  123. db,
  124. printer_manager,
  125. ams_mapping: list[int] | None = None,
  126. ):
  127. """Store Spoolman tracking data at print start (persisted to database).
  128. Only stores data when Spoolman is enabled and AMS weight sync is disabled
  129. (i.e., we're using per-usage tracking instead of AMS percentage estimates).
  130. """
  131. from backend.app.api.routes.settings import get_setting
  132. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  133. from backend.app.models.print_queue import PrintQueueItem
  134. from backend.app.utils.threemf_tools import (
  135. extract_filament_properties_from_3mf,
  136. extract_filament_usage_from_3mf,
  137. extract_layer_filament_usage_from_3mf,
  138. )
  139. # Check if Spoolman is enabled
  140. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  141. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  142. return
  143. # Only store tracking data if "Disable AMS Weight Sync" is enabled
  144. disable_weight_sync_str = await get_setting(db, "spoolman_disable_weight_sync")
  145. disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == "true"
  146. if not disable_weight_sync:
  147. logger.debug("[SPOOLMAN] Weight sync enabled, skipping per-usage tracking data storage")
  148. return
  149. # Get 3MF file path
  150. full_path = app_settings.base_dir / file_path
  151. if not full_path.exists():
  152. logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
  153. return
  154. # Extract per-filament usage from 3MF (total usage per slot)
  155. filament_usage = extract_filament_usage_from_3mf(full_path)
  156. if not filament_usage:
  157. logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
  158. return
  159. # Get current AMS tray state
  160. state = printer_manager.get_status(printer_id)
  161. ams_trays = {}
  162. if state and state.raw_data:
  163. ams_trays = build_ams_tray_lookup(state.raw_data)
  164. # Prefer the explicit mapping captured from the print command, then fall back
  165. # to any queue mapping stored for scheduled/reprint jobs.
  166. slot_to_tray = ams_mapping if ams_mapping is not None else None
  167. if not slot_to_tray:
  168. queue_result = await db.execute(
  169. select(PrintQueueItem)
  170. .where(PrintQueueItem.archive_id == archive_id)
  171. .where(PrintQueueItem.status == "printing")
  172. )
  173. queue_item = queue_result.scalar_one_or_none()
  174. if queue_item and queue_item.ams_mapping:
  175. try:
  176. slot_to_tray = json.loads(queue_item.ams_mapping)
  177. except json.JSONDecodeError:
  178. pass # Ignore malformed AMS mapping; fall back to default slot assignment
  179. # Parse G-code for per-layer filament usage (for accurate partial usage tracking)
  180. layer_usage = extract_layer_filament_usage_from_3mf(full_path)
  181. layer_usage_json = None
  182. if layer_usage:
  183. # Convert int keys to string for JSON serialization
  184. layer_usage_json = {str(k): v for k, v in layer_usage.items()}
  185. logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
  186. # Extract filament properties (density, diameter) for mm -> grams conversion
  187. filament_properties = extract_filament_properties_from_3mf(full_path)
  188. # Delete any existing row for this printer/archive (shouldn't exist, but just in case)
  189. await db.execute(
  190. delete(ActivePrintSpoolman)
  191. .where(ActivePrintSpoolman.printer_id == printer_id)
  192. .where(ActivePrintSpoolman.archive_id == archive_id)
  193. )
  194. # Insert new tracking data
  195. tracking = ActivePrintSpoolman(
  196. printer_id=printer_id,
  197. archive_id=archive_id,
  198. filament_usage=filament_usage,
  199. ams_trays=ams_trays,
  200. slot_to_tray=slot_to_tray,
  201. layer_usage=layer_usage_json,
  202. filament_properties=filament_properties,
  203. )
  204. db.add(tracking)
  205. await db.commit()
  206. logger.info("[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s", printer_id, archive_id)
  207. logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
  208. logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
  209. if slot_to_tray:
  210. logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
  211. if layer_usage_json:
  212. logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
  213. async def cleanup_tracking(
  214. printer_id: int,
  215. archive_id: int,
  216. db,
  217. last_layer_num: int | None = None,
  218. last_progress: int | None = None,
  219. ):
  220. """Report partial usage and clean up Spoolman tracking data for failed/aborted prints."""
  221. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  222. # Get tracking data first (needed for partial usage reporting)
  223. result = await db.execute(
  224. select(ActivePrintSpoolman)
  225. .where(ActivePrintSpoolman.printer_id == printer_id)
  226. .where(ActivePrintSpoolman.archive_id == archive_id)
  227. )
  228. tracking = result.scalar_one_or_none()
  229. if not tracking:
  230. logger.debug("[SPOOLMAN] No tracking data to clean up for printer=%s, archive=%s", printer_id, archive_id)
  231. return
  232. # Try to report partial usage before cleanup
  233. try:
  234. await _report_partial_usage(
  235. printer_id,
  236. tracking,
  237. last_layer_num=last_layer_num,
  238. last_progress=last_progress,
  239. )
  240. except Exception as e:
  241. logger.warning("[SPOOLMAN] Partial usage report failed: %s", e)
  242. # Delete tracking data
  243. await db.execute(
  244. delete(ActivePrintSpoolman)
  245. .where(ActivePrintSpoolman.printer_id == printer_id)
  246. .where(ActivePrintSpoolman.archive_id == archive_id)
  247. )
  248. await db.commit()
  249. logger.debug("[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s", printer_id, archive_id)
  250. async def _get_spoolman_client_with_fallback():
  251. """Get Spoolman client, initializing from settings if needed.
  252. Returns (client, is_healthy) tuple. Client may be None.
  253. """
  254. client = await get_spoolman_client()
  255. if not client:
  256. async with async_session() as db:
  257. from backend.app.api.routes.settings import get_setting
  258. spoolman_url = await get_setting(db, "spoolman_url")
  259. if spoolman_url:
  260. try:
  261. client = await init_spoolman_client(spoolman_url)
  262. except ValueError as exc:
  263. logger.warning("Spoolman URL %r rejected by SSRF guard: %s", spoolman_url, exc)
  264. return None
  265. if not client:
  266. return None
  267. if not await client.health_check():
  268. logger.warning("Spoolman health check failed; skipping usage reporting")
  269. return None
  270. return client
  271. async def _report_spool_usage_for_slots(
  272. client,
  273. filament_usage_items: list[tuple[int, float]],
  274. ams_trays: dict[int, dict],
  275. slot_to_tray: list | None,
  276. method_label: str,
  277. printer_serial: str = "",
  278. ) -> int:
  279. """Report usage to Spoolman for a list of (slot_id, grams) pairs.
  280. Returns number of spools successfully updated.
  281. """
  282. spools_updated = 0
  283. for slot_id, grams_used in filament_usage_items:
  284. if grams_used <= 0:
  285. continue
  286. global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
  287. tray_info = ams_trays.get(global_tray_id)
  288. if not tray_info:
  289. logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
  290. continue
  291. is_external = global_tray_id >= 254
  292. tray_type = tray_info.get("tray_type", "")
  293. logger.debug(
  294. "[SPOOLMAN] Slot %s resolved to global_tray_id %s (tray_type=%s, external=%s)",
  295. slot_id,
  296. global_tray_id,
  297. tray_type or "unknown",
  298. is_external,
  299. )
  300. spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
  301. if not spool_tag:
  302. logger.debug("[SPOOLMAN] Slot %s: no identifier for tray %s", slot_id, global_tray_id)
  303. continue
  304. spool = await client.find_spool_by_tag(spool_tag)
  305. if not spool:
  306. logger.debug("[SPOOLMAN] Slot %s: no spool for tag %s...", slot_id, spool_tag[:16])
  307. continue
  308. result = await client.use_spool(spool["id"], grams_used)
  309. if result:
  310. logger.info("[SPOOLMAN] %s: slot %s: %sg -> spool %s", method_label, slot_id, grams_used, spool["id"])
  311. spools_updated += 1
  312. return spools_updated
  313. async def _report_partial_usage(
  314. printer_id: int,
  315. tracking,
  316. last_layer_num: int | None = None,
  317. last_progress: int | None = None,
  318. ):
  319. """Report partial filament usage based on actual G-code layer data.
  320. Uses per-layer cumulative extrusion from G-code parsing for accurate
  321. multi-material tracking. Falls back to linear interpolation if G-code
  322. data is unavailable.
  323. """
  324. from backend.app.services.printer_manager import printer_manager
  325. from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer, mm_to_grams
  326. async with async_session() as db:
  327. from backend.app.api.routes.settings import get_setting
  328. # Check if partial usage reporting is enabled (default: true)
  329. report_partial = await get_setting(db, "spoolman_report_partial_usage")
  330. if report_partial and report_partial.lower() == "false":
  331. logger.debug("[SPOOLMAN] Partial usage reporting disabled by setting")
  332. return
  333. # Check if Spoolman is enabled
  334. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  335. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  336. return
  337. # Get current printer state for layer progress.
  338. # On failed/aborted prints the firmware may already reset to IDLE with layer=0,
  339. # so we fall back to completion-time hints captured from MQTT.
  340. state = printer_manager.get_status(printer_id)
  341. current_layer = state.layer_num if state else None
  342. total_layers = state.total_layers if state else None
  343. if (not current_layer or current_layer <= 0) and last_layer_num and last_layer_num > 0:
  344. current_layer = last_layer_num
  345. logger.debug("[SPOOLMAN] Using captured last_layer_num=%s for partial usage", current_layer)
  346. progress_ratio_from_event = None
  347. if last_progress is not None:
  348. try:
  349. progress_ratio_from_event = min(max(float(last_progress), 0.0), 100.0) / 100.0
  350. except (TypeError, ValueError):
  351. progress_ratio_from_event = None
  352. if (not current_layer or current_layer <= 0) and progress_ratio_from_event and total_layers and total_layers > 0:
  353. current_layer = max(1, int(round(total_layers * progress_ratio_from_event)))
  354. logger.debug(
  355. "[SPOOLMAN] Estimated layer from last_progress=%s%% and total_layers=%s -> %s",
  356. last_progress,
  357. total_layers,
  358. current_layer,
  359. )
  360. if not current_layer or current_layer <= 0:
  361. logger.debug(
  362. "[SPOOLMAN] No progress to report (layer 0/unknown, last_layer_num=%s, last_progress=%s)",
  363. last_layer_num,
  364. last_progress,
  365. )
  366. return
  367. logger.info("[SPOOLMAN] Reporting partial usage at layer %s/%s", current_layer, total_layers or "?")
  368. # Get tracking data
  369. layer_usage = tracking.layer_usage
  370. filament_properties = tracking.filament_properties or {}
  371. filament_usage = tracking.filament_usage or []
  372. ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
  373. slot_to_tray = tracking.slot_to_tray
  374. printer_serial = await _get_printer_serial(printer_id)
  375. client = await _get_spoolman_client_with_fallback()
  376. if not client:
  377. logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
  378. return
  379. # Try to use accurate G-code parsed data
  380. if layer_usage:
  381. layer_usage_int = {
  382. int(layer): {int(fid): mm for fid, mm in filaments.items()} for layer, filaments in layer_usage.items()
  383. }
  384. usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)
  385. if usage_mm:
  386. logger.info("[SPOOLMAN] Using G-code parsed data for layer %s", current_layer)
  387. # Build (slot_id, grams) list using Spoolman densities with 3MF fallback
  388. usage_items = []
  389. for filament_id, mm_used in usage_mm.items():
  390. slot_id = filament_id + 1 # filament_id is 0-based, slot_id is 1-based
  391. # Get density from Spoolman (most accurate), fall back to 3MF, then PLA default
  392. global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)
  393. tray_info = ams_trays.get(global_tray_id)
  394. density = None
  395. diameter = 1.75
  396. if tray_info:
  397. spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
  398. if spool_tag:
  399. spool = await client.find_spool_by_tag(spool_tag)
  400. if spool:
  401. filament_data = spool.get("filament", {})
  402. density = filament_data.get("density")
  403. diameter = filament_data.get("diameter", 1.75)
  404. if not density:
  405. props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))
  406. density = props.get("density", 1.24)
  407. logger.debug("[SPOOLMAN] Using fallback density %s for slot %s", density, slot_id)
  408. grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
  409. usage_items.append((slot_id, grams_used))
  410. spools_updated = await _report_spool_usage_for_slots(
  411. client, usage_items, ams_trays, slot_to_tray, "Partial (G-code)", printer_serial
  412. )
  413. if spools_updated > 0:
  414. logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
  415. return
  416. # Fallback: linear interpolation (if no G-code data available)
  417. progress_ratio = None
  418. if total_layers and total_layers > 0:
  419. progress_ratio = min(current_layer / total_layers, 1.0)
  420. elif progress_ratio_from_event is not None:
  421. progress_ratio = progress_ratio_from_event
  422. if progress_ratio is None:
  423. logger.debug(
  424. "[SPOOLMAN] Cannot use linear fallback: total_layers=%s, last_progress=%s",
  425. total_layers,
  426. last_progress,
  427. )
  428. return
  429. logger.info("[SPOOLMAN] Falling back to linear interpolation (%s)", progress_ratio)
  430. usage_items = []
  431. for usage in filament_usage:
  432. slot_id = usage.get("slot_id", 0)
  433. total_used_g = usage.get("used_g", 0)
  434. if total_used_g > 0:
  435. partial_used_g = round(total_used_g * progress_ratio, 2)
  436. usage_items.append((slot_id, partial_used_g))
  437. spools_updated = await _report_spool_usage_for_slots(
  438. client, usage_items, ams_trays, slot_to_tray, "Partial (linear)", printer_serial
  439. )
  440. if spools_updated > 0:
  441. logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
  442. async def report_usage(printer_id: int, archive_id: int):
  443. """Report filament usage to Spoolman after print completion.
  444. Uses per-filament usage data captured at print start to report
  445. usage to the correct spools.
  446. """
  447. async with async_session() as db:
  448. from backend.app.api.routes.settings import get_setting
  449. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  450. # Get tracking data stored at print start
  451. result = await db.execute(
  452. select(ActivePrintSpoolman)
  453. .where(ActivePrintSpoolman.printer_id == printer_id)
  454. .where(ActivePrintSpoolman.archive_id == archive_id)
  455. )
  456. tracking = result.scalar_one_or_none()
  457. if not tracking:
  458. logger.info("[SPOOLMAN] No tracking data for print (printer=%s, archive=%s)", printer_id, archive_id)
  459. return
  460. filament_usage = tracking.filament_usage or []
  461. ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
  462. slot_to_tray = tracking.slot_to_tray
  463. printer_serial = await _get_printer_serial(printer_id)
  464. # Delete tracking row (we're done with it)
  465. await db.delete(tracking)
  466. await db.commit()
  467. if not filament_usage:
  468. logger.debug("[SPOOLMAN] No filament usage data for archive %s", archive_id)
  469. return
  470. # Check if Spoolman is enabled
  471. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  472. if not spoolman_enabled or spoolman_enabled.lower() != "true":
  473. return
  474. client = await _get_spoolman_client_with_fallback()
  475. if not client:
  476. logger.warning("[SPOOLMAN] Not reachable for usage reporting")
  477. return
  478. logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
  479. usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
  480. spools_updated = await _report_spool_usage_for_slots(
  481. client, usage_items, ams_trays, slot_to_tray, f"Archive {archive_id}", printer_serial
  482. )
  483. if spools_updated == 0:
  484. logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
  485. else:
  486. logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)