usage_tracker.py 72 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730
  1. """Automatic filament consumption tracking.
  2. Captures AMS tray remain% at print start, then computes consumption
  3. deltas at print complete to update spool weight_used and last_used.
  4. Primary tracking uses 3MF slicer estimates (precise per-filament data).
  5. AMS remain% delta is the fallback for trays not covered by 3MF data.
  6. """
  7. import asyncio
  8. import json
  9. import logging
  10. from dataclasses import dataclass, field
  11. from datetime import datetime, timezone
  12. from sqlalchemy import select
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.models.spool import Spool
  15. from backend.app.models.spool_assignment import SpoolAssignment
  16. from backend.app.models.spool_usage_history import SpoolUsageHistory
  17. logger = logging.getLogger(__name__)
  18. def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
  19. """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
  20. The printer's MQTT mapping field is an array indexed by slicer filament slot
  21. (0-based). Each value uses snow encoding: ams_hw_id * 256 + local_slot.
  22. 65535 means unmapped.
  23. Returns a list of bambuddy global tray IDs (or -1 for unmapped), or None if
  24. no valid mappings found.
  25. """
  26. if not isinstance(mapping_raw, list) or not mapping_raw:
  27. return None
  28. result = []
  29. for value in mapping_raw:
  30. if not isinstance(value, int) or value >= 65535:
  31. result.append(-1)
  32. continue
  33. ams_hw_id = value >> 8
  34. slot = value & 0xFF
  35. if 0 <= ams_hw_id <= 3:
  36. # Regular AMS: sequential global ID
  37. result.append(ams_hw_id * 4 + (slot & 0x03))
  38. elif 128 <= ams_hw_id <= 135:
  39. # AMS-HT: global ID is the hardware ID (one slot per unit)
  40. result.append(ams_hw_id)
  41. elif ams_hw_id in (254, 255):
  42. # External spool
  43. result.append(254 if slot != 255 else 255)
  44. else:
  45. result.append(-1)
  46. # Only return if at least one valid mapping exists
  47. if all(v < 0 for v in result):
  48. return None
  49. return result
  50. def _spool_color_to_hex(rgba: str | None) -> str | None:
  51. """Normalise a ``Spool.rgba`` value (``RRGGBBAA`` hex, no ``#``) to the
  52. ``#RRGGBB`` form archives store in ``filament_color``.
  53. Alpha is dropped — the archive colour list and the Color Distribution
  54. graph treat filament colour as opaque. Returns ``None`` for a missing or
  55. too-short value so the caller can fall back to the 3MF colour.
  56. """
  57. if not rgba:
  58. return None
  59. h = rgba.strip().lstrip("#")
  60. if len(h) < 6:
  61. return None
  62. return "#" + h[:6].upper()
  63. def _archive_colors_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
  64. """Slot-ordered, de-duplicated hex colours for an archive's ``filament_color``,
  65. taken from the inventory spools that actually fed the print (#1494).
  66. The slicer's 3MF carries its own ``filament_colour`` per slot — a value
  67. picked independently of the colour the user curates on the matched
  68. inventory spool. So an archive printed from a ``#000000`` inventory spool
  69. would otherwise show the slicer's near-black ``#161616``. Once usage
  70. tracking has resolved the used slots to spools, the spool colours are the
  71. authoritative source and replace the 3MF values.
  72. Returns ``None`` — leave the 3MF colour untouched — unless *every* slot
  73. with non-zero usage was matched to a spool that carries a colour. A
  74. partial rewrite would silently drop the unmatched slots' colours from the
  75. archive (and the Color Distribution graph), so it is all-or-nothing.
  76. """
  77. used_slots = {u["slot_id"] for u in filament_usage if u.get("used_g", 0) > 0 and u.get("slot_id") is not None}
  78. if not used_slots:
  79. return None
  80. slot_color: dict[int, str] = {}
  81. for r in results:
  82. slot_id = r.get("slot_id")
  83. color = r.get("color")
  84. if slot_id is not None and color:
  85. slot_color.setdefault(slot_id, color)
  86. if not used_slots.issubset(slot_color):
  87. return None
  88. ordered: list[str] = []
  89. for slot_id in sorted(used_slots):
  90. color = slot_color[slot_id]
  91. if color not in ordered:
  92. ordered.append(color)
  93. return ordered
  94. def _archive_types_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
  95. """Slot-ordered, de-duplicated materials for an archive's ``filament_type``,
  96. taken from the inventory spools that actually fed the print (#2563).
  97. The slicer's 3MF records the filament type it was *sliced for*. When the
  98. user manually maps a slot to a differently-typed loaded spool in the Print
  99. dialog — a PLA slice routed to the only loaded PETG slot — that sliced type
  100. misclassifies the run in the archive card, the Print Log and the material
  101. statistics, even though the deduction correctly hit the PETG spool. Once
  102. usage tracking has resolved every used slot to an inventory spool, the
  103. spool's declared material is the authoritative record of what was consumed,
  104. the same reasoning that already adopts the spool colour (#1494).
  105. Returns ``None`` — leave the 3MF type untouched — unless *every* slot with
  106. non-zero usage was matched to a spool that carries a material. All-or-
  107. nothing, exactly like ``_archive_colors_from_spools``: a partial rewrite
  108. would silently drop the unmatched slots' types from the archive (and the
  109. material stats).
  110. """
  111. used_slots = {u["slot_id"] for u in filament_usage if u.get("used_g", 0) > 0 and u.get("slot_id") is not None}
  112. if not used_slots:
  113. return None
  114. slot_material: dict[int, str] = {}
  115. for r in results:
  116. slot_id = r.get("slot_id")
  117. material = (r.get("material") or "").strip()
  118. if slot_id is not None and material:
  119. slot_material.setdefault(slot_id, material)
  120. if not used_slots.issubset(slot_material):
  121. return None
  122. ordered: list[str] = []
  123. for slot_id in sorted(used_slots):
  124. material = slot_material[slot_id]
  125. if material not in ordered:
  126. ordered.append(material)
  127. return ordered
  128. def _match_slots_by_color(
  129. filament_usage: list[dict],
  130. ams_raw: dict | list | None,
  131. ) -> list[int] | None:
  132. """Match 3MF filament slots to AMS trays by color.
  133. Fallback mapping for printers that don't provide the MQTT mapping field
  134. or request topic subscription (e.g. A1, A1 Mini, P1S, P2S).
  135. Compares the 3MF slicer filament color (per slot) against each AMS tray's
  136. color to find a unique match. Only returns a mapping if every used slot
  137. matches exactly one tray (no ambiguity).
  138. Args:
  139. filament_usage: List of 3MF slot dicts with 'slot_id', 'color', 'type'
  140. ams_raw: raw_data["ams"] dict or list from printer state
  141. Returns:
  142. List of global tray IDs indexed by slicer slot (0-based), or None.
  143. """
  144. if not filament_usage or not ams_raw:
  145. return None
  146. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  147. if not ams_data:
  148. return None
  149. # Build map of normalized color → list of global tray IDs
  150. color_to_trays: dict[str, list[int]] = {}
  151. for ams_unit in ams_data:
  152. ams_id = int(ams_unit.get("id", 0))
  153. for tray in ams_unit.get("tray", []):
  154. tray_id = int(tray.get("id", 0))
  155. tray_color = tray.get("tray_color", "")
  156. tray_type = tray.get("tray_type", "")
  157. if not tray_color or not tray_type:
  158. continue
  159. # Normalize AMS color: strip alpha (last 2 chars), lowercase
  160. norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
  161. if ams_id >= 128:
  162. global_id = ams_id # AMS-HT
  163. else:
  164. global_id = ams_id * 4 + tray_id
  165. color_to_trays.setdefault(norm, []).append(global_id)
  166. if not color_to_trays:
  167. return None
  168. # Find max slot_id to size the result array
  169. max_slot = max(u.get("slot_id", 0) for u in filament_usage)
  170. if max_slot <= 0:
  171. return None
  172. result = [-1] * max_slot
  173. used_trays: set[int] = set()
  174. for usage in filament_usage:
  175. slot_id = usage.get("slot_id", 0)
  176. if slot_id <= 0:
  177. continue
  178. slot_color = usage.get("color", "").lstrip("#").lower()
  179. if len(slot_color) < 6:
  180. return None # Can't match without a valid color
  181. slot_color = slot_color[:6] # Strip alpha if present
  182. candidates = color_to_trays.get(slot_color, [])
  183. # Filter out trays already claimed by another slot
  184. available = [t for t in candidates if t not in used_trays]
  185. if len(available) != 1:
  186. # Ambiguous (multiple trays with same color) or no match
  187. return None
  188. result[slot_id - 1] = available[0]
  189. used_trays.add(available[0])
  190. # Only return if at least one valid mapping exists
  191. if all(v < 0 for v in result):
  192. return None
  193. logger.info("[UsageTracker] Color-matched slot_to_tray: %s", result)
  194. return result
  195. @dataclass
  196. class PrintSession:
  197. printer_id: int
  198. print_name: str
  199. started_at: datetime
  200. tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
  201. # tray_now at print start (correct value, unlike at completion where it's 255)
  202. tray_now_at_start: int = -1
  203. # Snapshot of spool assignments at print start: {(ams_id, tray_id): spool_id}
  204. # Prevents usage loss when on_ams_change unlinks a spool mid-print
  205. spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
  206. # AMS mapping from print command (captured at start, needed when auto-archive is off)
  207. ams_mapping: list[int] | None = None
  208. # Queue item's plate_id when this print is a multi-plate 3MF dispatched for a
  209. # single plate (#1697). None for non-queue prints — the file's first/only plate
  210. # is the default and the 3MF parser already returns the full file in that case.
  211. plate_id: int | None = None
  212. # Module-level storage, keyed by printer_id. Mirrored to the
  213. # ``active_print_sessions`` table so a restart mid-print doesn't lose the
  214. # context — see ``persist_session`` / ``restore_session``.
  215. _active_sessions: dict[int, PrintSession] = {}
  216. # Serialises the read-modify-write on the persisted tray-change log, per printer.
  217. _tray_change_locks: dict[int, asyncio.Lock] = {}
  218. def _tray_key_to_str(key: tuple[int, int]) -> str:
  219. return f"{key[0]}-{key[1]}"
  220. def _tray_key_from_str(key: str) -> tuple[int, int] | None:
  221. ams_str, _, tray_str = key.partition("-")
  222. try:
  223. return int(ams_str), int(tray_str)
  224. except ValueError:
  225. return None
  226. def _tray_map_to_json(mapping: dict[tuple[int, int], int]) -> dict[str, int]:
  227. return {_tray_key_to_str(k): v for k, v in mapping.items()}
  228. def _tray_map_from_json(mapping: dict | None) -> dict[tuple[int, int], int]:
  229. result: dict[tuple[int, int], int] = {}
  230. for raw_key, value in (mapping or {}).items():
  231. key = _tray_key_from_str(str(raw_key))
  232. if key is not None and isinstance(value, int):
  233. result[key] = value
  234. return result
  235. async def persist_session(
  236. db: AsyncSession,
  237. session: PrintSession,
  238. tray_change_log: list | None = None,
  239. ) -> None:
  240. """Mirror ``session`` into ``active_print_sessions`` for restart recovery.
  241. Overwrites any existing row for the printer: a printer runs one print at a
  242. time, and a row left behind by a completion we never saw must not outlive
  243. the next print start.
  244. """
  245. from backend.app.models.active_print_session import ActivePrintSession
  246. row = await db.get(ActivePrintSession, session.printer_id)
  247. if row is None:
  248. row = ActivePrintSession(printer_id=session.printer_id)
  249. db.add(row)
  250. row.print_name = session.print_name or ""
  251. row.started_at = session.started_at.replace(tzinfo=None)
  252. row.tray_now_at_start = session.tray_now_at_start
  253. row.plate_id = session.plate_id
  254. row.ams_mapping = list(session.ams_mapping) if session.ams_mapping else None
  255. row.spool_assignments = _tray_map_to_json(session.spool_assignments) or None
  256. row.tray_remain_start = _tray_map_to_json(session.tray_remain_start) or None
  257. row.tray_change_log = [list(entry) for entry in (tray_change_log or [])] or None
  258. await db.commit()
  259. async def record_tray_change(db: AsyncSession, printer_id: int, tray_global: int, layer_num: int) -> None:
  260. """Append one tray change to the persisted log.
  261. No-op when no print-start row exists — a tray change outside a tracked
  262. print has nothing to attribute.
  263. """
  264. from backend.app.models.active_print_session import ActivePrintSession
  265. # Read-modify-write on a JSON column: two changes close together (a runout
  266. # parks the extruder and the backup tray loads moments later) would
  267. # otherwise race and drop a segment boundary.
  268. async with _tray_change_locks.setdefault(printer_id, asyncio.Lock()):
  269. row = await db.get(ActivePrintSession, printer_id)
  270. if row is None:
  271. return
  272. log = [list(entry) for entry in (row.tray_change_log or [])]
  273. entry = [tray_global, layer_num]
  274. if log and log[-1] == entry:
  275. # print-start seeds the log from PrinterState, which may already
  276. # hold a change this callback is also reporting.
  277. return
  278. log.append(entry)
  279. row.tray_change_log = log
  280. await db.commit()
  281. async def get_persisted_print_name(db: AsyncSession, printer_id: int) -> str | None:
  282. """Print name on the persisted row, for identity-checking a restored session."""
  283. from backend.app.models.active_print_session import ActivePrintSession
  284. row = await db.get(ActivePrintSession, printer_id)
  285. return row.print_name if row is not None else None
  286. async def restore_session(db: AsyncSession, printer_id: int, register_active: bool = True) -> list[list[int]] | None:
  287. """Rebuild the in-memory session for ``printer_id`` from the persisted row.
  288. Returns the persisted tray-change log so the caller can put it back on
  289. ``PrinterState``, or None when there is nothing to restore.
  290. ``register_active=False`` returns the log without publishing the session to
  291. ``_active_sessions`` — for Spoolman users, who need the tray-change log
  292. restored but whose remain%-sync must not be suppressed by it (see
  293. ``on_print_start``).
  294. """
  295. from backend.app.models.active_print_session import ActivePrintSession
  296. row = await db.get(ActivePrintSession, printer_id)
  297. if row is None:
  298. return None
  299. started_at = row.started_at
  300. if started_at.tzinfo is None:
  301. started_at = started_at.replace(tzinfo=timezone.utc)
  302. session = PrintSession(
  303. printer_id=printer_id,
  304. print_name=row.print_name or "",
  305. started_at=started_at,
  306. tray_remain_start=_tray_map_from_json(row.tray_remain_start),
  307. tray_now_at_start=row.tray_now_at_start,
  308. spool_assignments=_tray_map_from_json(row.spool_assignments),
  309. ams_mapping=list(row.ams_mapping) if row.ams_mapping else None,
  310. plate_id=row.plate_id,
  311. )
  312. if register_active:
  313. _active_sessions[printer_id] = session
  314. log = [list(entry) for entry in (row.tray_change_log or [])]
  315. logger.info(
  316. "[UsageTracker] Restored print session for printer %d: plate_id=%s, ams_mapping=%s, "
  317. "%d assignments, tray_change_log=%s",
  318. printer_id,
  319. row.plate_id,
  320. row.ams_mapping,
  321. len(row.spool_assignments or {}),
  322. log,
  323. )
  324. return log
  325. async def clear_persisted_session(db: AsyncSession, printer_id: int) -> None:
  326. """Drop the persisted print-start row once the print is closed out."""
  327. from backend.app.models.active_print_session import ActivePrintSession
  328. row = await db.get(ActivePrintSession, printer_id)
  329. if row is not None:
  330. await db.delete(row)
  331. await db.commit()
  332. async def discard_session(db: AsyncSession, printer_id: int) -> None:
  333. """Forget a printer's print-start context, in memory and on disk.
  334. The completion path calls this for every print, including the ones whose
  335. usage Spoolman owns: the context is captured for both backends, but only
  336. the internal tracker's ``on_print_complete`` consumes (and pops) it.
  337. """
  338. _active_sessions.pop(printer_id, None)
  339. _tray_change_locks.pop(printer_id, None)
  340. await clear_persisted_session(db, printer_id)
  341. def _to_epoch_seconds(value: datetime | None) -> float | None:
  342. """Convert datetime to epoch seconds, assuming UTC for naive values."""
  343. if value is None:
  344. return None
  345. dt = value
  346. if dt.tzinfo is None:
  347. dt = dt.replace(tzinfo=timezone.utc)
  348. return dt.timestamp()
  349. async def _resolve_spool_id_for_tray(
  350. printer_id: int,
  351. ams_id: int,
  352. tray_id: int,
  353. db: AsyncSession,
  354. spool_assignments_snapshot: dict[tuple[int, int], int] | None = None,
  355. print_started_at: datetime | None = None,
  356. ) -> int | None:
  357. """Resolve spool ID for a tray with safe support for mid-print reassignment.
  358. Resolution order:
  359. 1. If snapshot exists and live assignment changed *during this print*, use live spool.
  360. 2. Otherwise use snapshot spool when available.
  361. 3. Fall back to live assignment.
  362. """
  363. key = (ams_id, tray_id)
  364. snapshot_spool_id = spool_assignments_snapshot.get(key) if spool_assignments_snapshot else None
  365. # Backward-compatible fast path: if we have a snapshot but no print-start
  366. # timestamp, preserve legacy behavior and avoid extra DB lookups.
  367. if snapshot_spool_id is not None and print_started_at is None:
  368. return snapshot_spool_id
  369. result = await db.execute(
  370. select(SpoolAssignment).where(
  371. SpoolAssignment.printer_id == printer_id,
  372. SpoolAssignment.ams_id == ams_id,
  373. SpoolAssignment.tray_id == tray_id,
  374. )
  375. )
  376. live_assignment = result.scalar_one_or_none()
  377. if snapshot_spool_id is not None:
  378. if live_assignment and live_assignment.spool_id != snapshot_spool_id:
  379. live_created_ts = _to_epoch_seconds(getattr(live_assignment, "created_at", None))
  380. started_ts = _to_epoch_seconds(print_started_at)
  381. if live_created_ts is not None and started_ts is not None and live_created_ts >= started_ts:
  382. logger.info(
  383. "[UsageTracker] Assignment changed during print for printer %d AMS%d-T%d: snapshot spool %d -> live spool %d",
  384. printer_id,
  385. ams_id,
  386. tray_id,
  387. snapshot_spool_id,
  388. live_assignment.spool_id,
  389. )
  390. return live_assignment.spool_id
  391. return snapshot_spool_id
  392. if live_assignment:
  393. return live_assignment.spool_id
  394. return None
  395. async def on_print_start(
  396. printer_id: int,
  397. data: dict,
  398. printer_manager,
  399. db: AsyncSession | None = None,
  400. spoolman_owns_usage: bool = False,
  401. ) -> None:
  402. """Capture AMS tray remain% and spool assignments at print start.
  403. The capture runs for both inventory backends — the persisted row carries
  404. the tray-change log, which is the only record of which spool fed which
  405. layers when AMS Filament Backup swaps trays, and Spoolman's own durable
  406. row (#1820) does not hold it.
  407. ``spoolman_owns_usage`` keeps the in-memory session out of
  408. ``_active_sessions`` when Spoolman is writing the usage. That dict doubles
  409. as ``on_ams_change``'s "a print is running, so skip the remain%-based
  410. weight sync because the internal tracker will deduct precisely" flag
  411. (#880); registering a session the internal tracker will never complete
  412. would suppress a sync those users still need.
  413. """
  414. state = printer_manager.get_status(printer_id)
  415. if not state or not state.raw_data:
  416. logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
  417. return
  418. ams_raw = state.raw_data.get("ams", [])
  419. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  420. tray_remain_start: dict[tuple[int, int], int] = {}
  421. skipped_invalid: list[str] = []
  422. for ams_unit in ams_data:
  423. ams_id = int(ams_unit.get("id", 0))
  424. for tray in ams_unit.get("tray", []):
  425. tray_id = int(tray.get("id", 0))
  426. remain = tray.get("remain", -1)
  427. if isinstance(remain, int) and 0 <= remain <= 100:
  428. tray_remain_start[(ams_id, tray_id)] = remain
  429. else:
  430. skipped_invalid.append(f"AMS{ams_id}-T{tray_id}(remain={remain})")
  431. # Also capture VT (external) tray remain% — these are separate from AMS units
  432. vt_tray_raw = state.raw_data.get("vt_tray") or []
  433. if isinstance(vt_tray_raw, dict):
  434. vt_tray_raw = [vt_tray_raw]
  435. for vt in vt_tray_raw:
  436. if not isinstance(vt, dict):
  437. continue
  438. vt_id = int(vt.get("id", 254))
  439. # VT tray id 254 → (ams_id=255, tray_id=0), id 255 → (ams_id=255, tray_id=1)
  440. vt_tray_id = vt_id - 254
  441. remain = vt.get("remain", -1)
  442. if isinstance(remain, int) and 0 <= remain <= 100:
  443. tray_remain_start[(255, vt_tray_id)] = remain
  444. else:
  445. skipped_invalid.append(f"VT{vt_id}(remain={remain})")
  446. if skipped_invalid:
  447. logger.info(
  448. "[UsageTracker] Skipped trays with invalid remain%% for printer %d: %s",
  449. printer_id,
  450. ", ".join(skipped_invalid),
  451. )
  452. if not ams_data and not vt_tray_raw:
  453. logger.debug("[UsageTracker] No AMS or VT tray data for printer %d, skipping", printer_id)
  454. return
  455. print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
  456. # Capture tray_now at print start (reliable, unlike at completion where it's 255)
  457. tray_now_at_start = state.tray_now if state else -1
  458. # --- Diagnostic logging: dump mapping-related MQTT fields at print start ---
  459. # This helps us understand what each printer model reports for slot-to-tray mapping.
  460. mapping_field = state.raw_data.get("mapping")
  461. logger.info(
  462. "[UsageTracker] PRINT START printer %d: mapping=%s, tray_now=%d, last_loaded_tray=%s",
  463. printer_id,
  464. mapping_field,
  465. tray_now_at_start,
  466. getattr(state, "last_loaded_tray", "N/A"),
  467. )
  468. # Log all raw_data keys containing "map" or "ams" for discovery
  469. map_keys = {k: state.raw_data[k] for k in state.raw_data if "map" in k.lower()}
  470. if map_keys:
  471. logger.info("[UsageTracker] PRINT START printer %d: mapping-related keys: %s", printer_id, map_keys)
  472. # Log per-tray summary: tray_now, tray_tar, tray_type, tray_color for each slot
  473. for ams_unit in ams_data:
  474. ams_id = int(ams_unit.get("id", 0))
  475. tray_summary = []
  476. for tray in ams_unit.get("tray", []):
  477. tray_summary.append(
  478. f"T{tray.get('id', '?')}(type={tray.get('tray_type', '')}, "
  479. f"color={tray.get('tray_color', '')}, "
  480. f"now={ams_raw.get('tray_now', '?') if isinstance(ams_raw, dict) else '?'}, "
  481. f"tar={ams_raw.get('tray_tar', '?') if isinstance(ams_raw, dict) else '?'})"
  482. )
  483. logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
  484. # Snapshot spool assignments so usage isn't lost if on_ams_change unlinks mid-print
  485. spool_assignments: dict[tuple[int, int], int] = {}
  486. if db:
  487. assign_result = await db.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer_id))
  488. for assignment in assign_result.scalars().all():
  489. spool_assignments[(assignment.ams_id, assignment.tray_id)] = assignment.spool_id
  490. if spool_assignments:
  491. logger.info(
  492. "[UsageTracker] Snapshotted %d spool assignments for printer %d: %s",
  493. len(spool_assignments),
  494. printer_id,
  495. {f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
  496. )
  497. # Capture the queue item's plate_id so 3MF parsing at completion is scoped to
  498. # the plate that actually ran, not the whole multi-plate file (#1697).
  499. plate_id: int | None = None
  500. if db:
  501. from backend.app.models.print_queue import PrintQueueItem
  502. queue_result = await db.execute(
  503. select(PrintQueueItem)
  504. .where(PrintQueueItem.printer_id == printer_id)
  505. .where(PrintQueueItem.status == "printing")
  506. )
  507. queue_item = queue_result.scalars().first()
  508. if queue_item is not None:
  509. plate_id = queue_item.plate_id
  510. # Always create session (even without valid remain data) so print_name
  511. # is available at completion for 3MF-based tracking
  512. session = PrintSession(
  513. printer_id=printer_id,
  514. print_name=print_name,
  515. started_at=datetime.now(timezone.utc),
  516. tray_remain_start=tray_remain_start,
  517. tray_now_at_start=tray_now_at_start,
  518. spool_assignments=spool_assignments,
  519. ams_mapping=data.get("ams_mapping"),
  520. plate_id=plate_id,
  521. )
  522. if spoolman_owns_usage:
  523. _active_sessions.pop(printer_id, None)
  524. else:
  525. _active_sessions[printer_id] = session
  526. # Mirror to the DB so a restart mid-print doesn't lose the context. The
  527. # tray-change log has already been cleared and seeded with the starting
  528. # tray by bambu_mqtt before this callback fires.
  529. if db:
  530. try:
  531. await persist_session(db, session, getattr(state, "tray_change_log", None))
  532. except Exception:
  533. logger.exception("[UsageTracker] Failed to persist print session for printer %d", printer_id)
  534. if tray_remain_start:
  535. logger.info(
  536. "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
  537. printer_id,
  538. len(tray_remain_start),
  539. {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
  540. )
  541. else:
  542. logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
  543. async def on_print_complete(
  544. printer_id: int,
  545. data: dict,
  546. printer_manager,
  547. db: AsyncSession,
  548. archive_id: int | None = None,
  549. ams_mapping: list[int] | None = None,
  550. ) -> list[dict]:
  551. """Compute consumption deltas and update spool weight_used/last_used.
  552. Uses two tracking strategies in priority order:
  553. 1. 3MF per-filament estimates (primary) — precise slicer data for all spools
  554. 2. AMS remain% delta (fallback) — only for trays not already handled by 3MF
  555. Returns a list of dicts describing what was logged (for WebSocket broadcast).
  556. """
  557. from sqlalchemy import select
  558. from backend.app.api.routes.settings import get_setting
  559. from backend.app.models.spool_usage_history import SpoolUsageHistory
  560. session = _active_sessions.pop(printer_id, None)
  561. if session is None:
  562. # Restart mid-print: the in-memory session is gone but the print-start
  563. # row survived. Without this the completion path loses the plate, the
  564. # dispatched mapping and the assignment snapshot, and attributes the
  565. # whole print to whichever tray happened to finish it.
  566. try:
  567. await restore_session(db, printer_id)
  568. except Exception:
  569. logger.exception("[UsageTracker] Failed to restore print session for printer %d", printer_id)
  570. session = _active_sessions.pop(printer_id, None)
  571. status = data.get("status", "completed")
  572. results = []
  573. handled_trays: set[tuple[int, int]] = set()
  574. # Fetch default filament cost from settings for fallback
  575. default_cost_str = await get_setting(db, "default_filament_cost")
  576. default_filament_cost = float(default_cost_str) if default_cost_str else 0.0
  577. # Fall back to ams_mapping captured at print start (needed when auto-archive is off
  578. # and the caller can't retrieve the mapping from _print_ams_mappings without archive_id)
  579. if not ams_mapping and session and session.ams_mapping:
  580. ams_mapping = session.ams_mapping
  581. logger.info(
  582. "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s",
  583. printer_id,
  584. archive_id,
  585. "yes" if session else "no",
  586. ams_mapping,
  587. )
  588. # --- Diagnostic logging: dump mapping-related MQTT fields at print completion ---
  589. state = printer_manager.get_status(printer_id)
  590. if state and state.raw_data:
  591. logger.info(
  592. "[UsageTracker] PRINT COMPLETE printer %d: mapping=%s, tray_now=%s, last_loaded_tray=%s",
  593. printer_id,
  594. state.raw_data.get("mapping"),
  595. state.tray_now,
  596. getattr(state, "last_loaded_tray", "N/A"),
  597. )
  598. # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
  599. print_name = (
  600. (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
  601. )
  602. # When auto-archive is disabled (archive_id=None), try to find a 3MF by filename
  603. # from the library or previous archives so we can still track filament usage.
  604. threemf_path = None
  605. if not archive_id:
  606. from backend.app.core.config import settings as app_settings
  607. search_filename = data.get("filename") or data.get("subtask_name") or (session.print_name if session else "")
  608. if search_filename:
  609. threemf_path = await _find_3mf_by_filename(printer_id, search_filename, db, app_settings.base_dir)
  610. if archive_id or threemf_path:
  611. threemf_results = await _track_from_3mf(
  612. printer_id,
  613. archive_id,
  614. status,
  615. print_name,
  616. handled_trays,
  617. printer_manager,
  618. db,
  619. ams_mapping=ams_mapping,
  620. tray_now_at_start=session.tray_now_at_start if session else -1,
  621. last_progress=data.get("last_progress", 0.0),
  622. last_layer_num=data.get("last_layer_num", 0),
  623. default_filament_cost=default_filament_cost,
  624. spool_assignments=session.spool_assignments if session else None,
  625. print_started_at=session.started_at if session else None,
  626. threemf_path=threemf_path,
  627. plate_id=session.plate_id if session else None,
  628. )
  629. results.extend(threemf_results)
  630. # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---
  631. if session and session.tray_remain_start:
  632. state = printer_manager.get_status(printer_id)
  633. if state and state.raw_data:
  634. ams_raw = state.raw_data.get("ams", [])
  635. ams_data = (
  636. ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  637. )
  638. # Build set of trays actually involved in this print (#1269).
  639. # Without this guard, swapping a spool in an UNUSED slot mid-print
  640. # makes that slot's remain% drop to 0, which the fallback below
  641. # would otherwise charge to the originally-assigned spool.
  642. def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
  643. if global_tray_id >= 254:
  644. return (255, global_tray_id - 254)
  645. if global_tray_id >= 128:
  646. return (global_tray_id, 0)
  647. return (global_tray_id // 4, global_tray_id % 4)
  648. print_used_keys: set[tuple[int, int]] = set()
  649. if ams_mapping:
  650. for gid in ams_mapping:
  651. if isinstance(gid, int) and gid >= 0:
  652. print_used_keys.add(_global_to_ams_key(gid))
  653. for change in getattr(state, "tray_change_log", None) or []:
  654. if isinstance(change, (tuple, list)) and len(change) >= 1:
  655. gid = change[0]
  656. if isinstance(gid, int) and gid >= 0:
  657. print_used_keys.add(_global_to_ams_key(gid))
  658. if session.tray_now_at_start is not None and session.tray_now_at_start >= 0:
  659. print_used_keys.add(_global_to_ams_key(session.tray_now_at_start))
  660. # Collect all trays to check: AMS trays + VT (external) trays
  661. # Each entry: (ams_id_for_assignment, tray_id_for_assignment, current_remain, label)
  662. trays_to_check: list[tuple[int, int, int, str]] = []
  663. for ams_unit in ams_data:
  664. ams_id = int(ams_unit.get("id", 0))
  665. for tray in ams_unit.get("tray", []):
  666. tray_id = int(tray.get("id", 0))
  667. remain = tray.get("remain", -1)
  668. trays_to_check.append((ams_id, tray_id, remain, f"AMS{ams_id}-T{tray_id}"))
  669. # VT (external) trays — same remain% delta logic
  670. vt_tray_raw = state.raw_data.get("vt_tray") or []
  671. if isinstance(vt_tray_raw, dict):
  672. vt_tray_raw = [vt_tray_raw]
  673. for vt in vt_tray_raw:
  674. if not isinstance(vt, dict):
  675. continue
  676. vt_id = int(vt.get("id", 254))
  677. vt_tray_id = vt_id - 254 # 254→0, 255→1
  678. remain = vt.get("remain", -1)
  679. trays_to_check.append((255, vt_tray_id, remain, f"VT{vt_id}"))
  680. for assign_ams_id, assign_tray_id, current_remain, tray_label in trays_to_check:
  681. key = (assign_ams_id, assign_tray_id)
  682. if key in handled_trays:
  683. continue # Already tracked via 3MF
  684. if key not in session.tray_remain_start:
  685. continue
  686. # Skip trays the print never touched. Only enforce when we have
  687. # evidence of which trays the print used; if print_used_keys is
  688. # empty (no mapping, no change log, no tray_now_at_start) keep
  689. # the legacy behavior of scanning every tray.
  690. if print_used_keys and key not in print_used_keys:
  691. logger.info(
  692. "[UsageTracker] %s: not in print mapping/tray_change_log — skipping fallback for printer %d",
  693. tray_label,
  694. printer_id,
  695. )
  696. continue
  697. if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
  698. logger.info(
  699. "[UsageTracker] %s: invalid remain%% at completion (%s), skipping fallback for printer %d",
  700. tray_label,
  701. current_remain,
  702. printer_id,
  703. )
  704. continue
  705. start_remain = session.tray_remain_start[key]
  706. delta_pct = start_remain - current_remain
  707. if delta_pct <= 0:
  708. continue # No consumption or tray was refilled
  709. spool_id = await _resolve_spool_id_for_tray(
  710. printer_id=printer_id,
  711. ams_id=assign_ams_id,
  712. tray_id=assign_tray_id,
  713. db=db,
  714. spool_assignments_snapshot=session.spool_assignments,
  715. print_started_at=session.started_at,
  716. )
  717. if spool_id is None:
  718. logger.info(
  719. "[UsageTracker] %s: no spool assigned, skipping fallback for printer %d",
  720. tray_label,
  721. printer_id,
  722. )
  723. continue
  724. # Load spool
  725. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  726. spool = spool_result.scalar_one_or_none()
  727. if not spool:
  728. continue
  729. # Compute weight consumed
  730. weight_grams = (delta_pct / 100.0) * spool.label_weight
  731. # Update spool
  732. spool.weight_used = (spool.weight_used or 0) + weight_grams
  733. spool.last_used = datetime.now(timezone.utc)
  734. # Calculate cost for this usage
  735. cost = None
  736. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  737. if cost_per_kg > 0:
  738. cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
  739. # Insert usage history record
  740. history = SpoolUsageHistory(
  741. spool_id=spool.id,
  742. printer_id=printer_id,
  743. print_name=session.print_name,
  744. weight_used=round(weight_grams, 1),
  745. percent_used=delta_pct,
  746. status=status,
  747. cost=cost,
  748. archive_id=archive_id,
  749. )
  750. db.add(history)
  751. handled_trays.add(key)
  752. results.append(
  753. {
  754. "spool_id": spool.id,
  755. "weight_used": round(weight_grams, 1),
  756. "percent_used": delta_pct,
  757. "ams_id": assign_ams_id,
  758. "tray_id": assign_tray_id,
  759. "material": spool.material,
  760. "cost": cost,
  761. # AMS remain%-delta fallback has no 3MF slot — slot_id
  762. # stays None so it is excluded from the colour rewrite.
  763. "slot_id": None,
  764. "color": _spool_color_to_hex(spool.rgba),
  765. }
  766. )
  767. logger.info(
  768. "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d %s (AMS fallback, %s)",
  769. spool.id,
  770. weight_grams,
  771. delta_pct,
  772. printer_id,
  773. tray_label,
  774. status,
  775. )
  776. if results:
  777. await db.commit()
  778. # --- Update PrintArchive.cost from THIS print session only ---
  779. #
  780. # Cover any filament weight that wasn't tracked by an inventory spool with
  781. # the global default rate (#1344). Without this, a multi-color print where
  782. # only some AMS trays are mapped to inventory spools would record only the
  783. # mapped slots' share — e.g. $0.01 for a 110g print when 3 of 4 trays had
  784. # no spool record. The initial cost set by archive.py (total grams *
  785. # primary cost_per_kg) is fine on its own, but this block overwrites it,
  786. # so the overwrite must reconstruct the whole-print cost.
  787. if archive_id and results:
  788. from sqlalchemy import func, select
  789. from backend.app.models.archive import PrintArchive
  790. from backend.app.models.print_log import PrintLogEntry
  791. archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  792. archive = archive_result.scalar_one_or_none()
  793. if archive:
  794. total_cost = sum(r.get("cost", 0) or 0 for r in results)
  795. tracked_grams = sum(r.get("weight_used", 0) or 0 for r in results)
  796. archive_grams = archive.filament_used_grams or 0
  797. untracked_grams = max(0.0, archive_grams - tracked_grams)
  798. if untracked_grams > 0 and default_filament_cost > 0:
  799. total_cost += (untracked_grams / 1000.0) * default_filament_cost
  800. if total_cost > 0:
  801. # Only overwrite archive.cost on the first run. Reprint actuals
  802. # live in PrintLogEntry; the archive card keeps the first run's
  803. # cost so a failed reprint doesn't visually clobber a successful
  804. # 100 g/$X print with a 10 g/$X/10 partial (#1378).
  805. _existing_runs_result = await db.execute(
  806. select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id)
  807. )
  808. _existing_runs = _existing_runs_result.scalar()
  809. if not _existing_runs:
  810. archive.cost = round(total_cost, 2)
  811. await db.commit()
  812. return results
  813. async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
  814. """Try to find a 3MF file from library or a previous archive when the current archive has none.
  815. This handles fallback archives (FTP download failed) where the 3MF may already exist
  816. locally from a library upload or a previous successful print of the same file.
  817. """
  818. from pathlib import Path
  819. from backend.app.models.archive import PrintArchive
  820. from backend.app.models.library import LibraryFile
  821. # Derive search name from archive filename (e.g. "benchy.3mf" or "benchy.gcode.3mf")
  822. search_name = archive.filename or archive.print_name
  823. if not search_name:
  824. return None
  825. # Normalize: strip path parts, get base name
  826. search_name = search_name.split("/")[-1]
  827. search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
  828. if not search_base:
  829. return None
  830. # 1. Try library files matching the name (match base name at file boundary)
  831. try:
  832. lib_result = await db.execute(
  833. LibraryFile.active()
  834. .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
  835. .where(LibraryFile.file_path.ilike("%.3mf"))
  836. .order_by(LibraryFile.created_at.desc())
  837. .limit(3)
  838. )
  839. for lib_file in lib_result.scalars().all():
  840. lib_path = Path(lib_file.file_path)
  841. candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
  842. if candidate.exists() and candidate.suffix == ".3mf":
  843. logger.info("[UsageTracker] 3MF fallback: found library file %s for archive %s", candidate, archive.id)
  844. return candidate
  845. except Exception as e:
  846. logger.debug("[UsageTracker] 3MF fallback: library lookup failed: %s", e)
  847. # 2. Try previous archives with the same filename that have a valid file_path
  848. try:
  849. prev_result = await db.execute(
  850. select(PrintArchive)
  851. .where(PrintArchive.id != archive.id)
  852. .where(PrintArchive.printer_id == archive.printer_id)
  853. .where(PrintArchive.file_path != "")
  854. .where(PrintArchive.file_path.isnot(None))
  855. .where(
  856. PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
  857. )
  858. .order_by(PrintArchive.created_at.desc())
  859. .limit(3)
  860. )
  861. for prev_archive in prev_result.scalars().all():
  862. candidate = base_dir / prev_archive.file_path
  863. if candidate.exists() and candidate.suffix == ".3mf":
  864. logger.info(
  865. "[UsageTracker] 3MF fallback: found previous archive %s file for archive %s",
  866. prev_archive.id,
  867. archive.id,
  868. )
  869. return candidate
  870. except Exception as e:
  871. logger.debug("[UsageTracker] 3MF fallback: previous archive lookup failed: %s", e)
  872. return None
  873. async def _find_3mf_by_filename(
  874. printer_id: int,
  875. filename: str,
  876. db: AsyncSession,
  877. base_dir,
  878. ):
  879. """Find a 3MF file by filename from library or previous archives.
  880. Used when auto-archive is disabled and there's no archive_id, but we still
  881. need the 3MF slicer data for filament usage tracking.
  882. """
  883. from pathlib import Path
  884. from backend.app.models.archive import PrintArchive
  885. from backend.app.models.library import LibraryFile
  886. search_name = filename.split("/")[-1] if "/" in filename else filename
  887. search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
  888. if not search_base:
  889. return None
  890. # 1. Try library files matching the name
  891. try:
  892. lib_result = await db.execute(
  893. LibraryFile.active()
  894. .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
  895. .where(LibraryFile.file_path.ilike("%.3mf"))
  896. .order_by(LibraryFile.created_at.desc())
  897. .limit(3)
  898. )
  899. for lib_file in lib_result.scalars().all():
  900. lib_path = Path(lib_file.file_path)
  901. candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
  902. if candidate.exists() and candidate.suffix == ".3mf":
  903. logger.info("[UsageTracker] 3MF (no-archive): found library file %s for '%s'", candidate, filename)
  904. return candidate
  905. except Exception as e:
  906. logger.debug("[UsageTracker] 3MF (no-archive): library lookup failed: %s", e)
  907. # 2. Try previous archives with a valid 3MF file_path
  908. try:
  909. prev_result = await db.execute(
  910. select(PrintArchive)
  911. .where(PrintArchive.printer_id == printer_id)
  912. .where(PrintArchive.file_path != "")
  913. .where(PrintArchive.file_path.isnot(None))
  914. .where(
  915. PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
  916. )
  917. .order_by(PrintArchive.created_at.desc())
  918. .limit(3)
  919. )
  920. for prev_archive in prev_result.scalars().all():
  921. candidate = base_dir / prev_archive.file_path
  922. if candidate.exists() and candidate.suffix == ".3mf":
  923. logger.info(
  924. "[UsageTracker] 3MF (no-archive): found previous archive %s file for '%s'",
  925. prev_archive.id,
  926. filename,
  927. )
  928. return candidate
  929. except Exception as e:
  930. logger.debug("[UsageTracker] 3MF (no-archive): previous archive lookup failed: %s", e)
  931. return None
  932. async def _track_from_3mf(
  933. printer_id: int,
  934. archive_id: int | None,
  935. status: str,
  936. print_name: str,
  937. handled_trays: set[tuple[int, int]],
  938. printer_manager,
  939. db: AsyncSession,
  940. ams_mapping: list[int] | None = None,
  941. tray_now_at_start: int = -1,
  942. last_progress: float = 0.0,
  943. last_layer_num: int = 0,
  944. default_filament_cost: float = 0.0,
  945. spool_assignments: dict[tuple[int, int], int] | None = None,
  946. print_started_at: datetime | None = None,
  947. threemf_path=None,
  948. plate_id: int | None = None,
  949. ) -> list[dict]:
  950. """Track usage from 3MF per-filament slicer data (primary path).
  951. Uses slicer-estimated filament weight for all spools (BL and non-BL).
  952. For partial prints (failed/aborted), tries per-layer gcode data first,
  953. then falls back to linear scaling by progress.
  954. When archive_id is None (auto-archive disabled), a pre-resolved threemf_path
  955. can be provided to still track filament usage from slicer data.
  956. When ``plate_id`` is set (queue prints of a single plate from a multi-plate
  957. 3MF), only that plate's filaments contribute. Without it the 3MF parser sums
  958. every plate, which is correct for direct/library Print flows that always
  959. target the first or only plate (#1697).
  960. Slot-to-tray mapping priority:
  961. 1. Stored ams_mapping from print command (reprints/direct prints)
  962. 2. MQTT mapping field from printer state (universal, all print sources)
  963. 3. Queue item ams_mapping (for queue-initiated prints)
  964. 4. tray_now from printer state (for single-filament non-queue prints)
  965. 5. Position-based default using sorted available tray IDs (handles external spools)
  966. 6. Default mapping: slot_id - 1 = global_tray_id (last resort)
  967. """
  968. from pathlib import Path
  969. from backend.app.core.config import settings as app_settings
  970. from backend.app.models.archive import PrintArchive
  971. from backend.app.models.print_queue import PrintQueueItem
  972. from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
  973. file_path: Path | None = threemf_path
  974. archive: PrintArchive | None = None
  975. if file_path is None and archive_id:
  976. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  977. archive = result.scalar_one_or_none()
  978. if not archive:
  979. logger.info("[UsageTracker] 3MF: archive %s not found, skipping", archive_id)
  980. return []
  981. # Try archive's own file_path first
  982. if archive.file_path:
  983. candidate = app_settings.base_dir / archive.file_path
  984. if candidate.exists():
  985. file_path = candidate
  986. # Fallback: find 3MF from library or a previous archive with the same filename
  987. if file_path is None:
  988. file_path = await _resolve_3mf_fallback(archive, db, app_settings.base_dir)
  989. if file_path is None:
  990. logger.info("[UsageTracker] 3MF: no file available for archive %s, skipping", archive_id)
  991. return []
  992. # The queue item carries both the plate and the dispatched mapping; look it
  993. # up at most once. ``.first()`` rather than ``.scalar_one_or_none()``
  994. # because a batch dispatches one archive as several queue items, and
  995. # raising there would cost the print all of its usage tracking.
  996. _queue_item_lookup: list = []
  997. async def _dispatch_queue_item():
  998. if not _queue_item_lookup:
  999. if not archive_id:
  1000. _queue_item_lookup.append(None)
  1001. else:
  1002. queue_result = await db.execute(
  1003. select(PrintQueueItem)
  1004. .where(PrintQueueItem.archive_id == archive_id)
  1005. .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
  1006. )
  1007. _queue_item_lookup.append(queue_result.scalars().first())
  1008. return _queue_item_lookup[0]
  1009. # The caller's plate_id comes from the in-memory session, which a restart
  1010. # mid-print destroys. Both the archive and the queue item recorded the
  1011. # plate at dispatch — without falling back to them the parser sums every
  1012. # plate of a multi-plate file and charges the lot to one spool.
  1013. if plate_id is None:
  1014. if archive is not None and archive.plate_id is not None:
  1015. plate_id = archive.plate_id
  1016. logger.info("[UsageTracker] 3MF: plate_id=%s recovered from archive %s", plate_id, archive_id)
  1017. else:
  1018. plate_queue_item = await _dispatch_queue_item()
  1019. if plate_queue_item is not None and plate_queue_item.plate_id is not None:
  1020. plate_id = plate_queue_item.plate_id
  1021. logger.info(
  1022. "[UsageTracker] 3MF: plate_id=%s recovered from queue item %s",
  1023. plate_id,
  1024. plate_queue_item.id,
  1025. )
  1026. filament_usage = extract_filament_usage_from_3mf(file_path, plate_id)
  1027. if not filament_usage and plate_id is not None:
  1028. # The plate isn't in this file. That happens when the archive's own 3MF
  1029. # is gone and `_resolve_3mf_fallback` substituted a same-named file from
  1030. # the library that was sliced with different plates. Summing the whole
  1031. # file is wrong for a single-plate run, but it is closer than recording
  1032. # nothing at all — and unlike the silent whole-file sum this replaces,
  1033. # it says so.
  1034. filament_usage = extract_filament_usage_from_3mf(file_path, None)
  1035. if filament_usage:
  1036. logger.warning(
  1037. "[UsageTracker] 3MF: plate %s not present in %s — falling back to the whole-file total",
  1038. plate_id,
  1039. file_path,
  1040. )
  1041. plate_id = None
  1042. if not filament_usage:
  1043. logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
  1044. return []
  1045. logger.info("[UsageTracker] 3MF: archive %s, plate_id=%s, filament_usage=%s", archive_id, plate_id, filament_usage)
  1046. # --- Resolve slot-to-tray mapping ---
  1047. mapping_source = None
  1048. # 1. Use stored ams_mapping from the print command (reprints/direct prints)
  1049. slot_to_tray = ams_mapping
  1050. if slot_to_tray:
  1051. mapping_source = "print_cmd"
  1052. # 2. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
  1053. #
  1054. # Ranked above the live MQTT field on purpose: `mapping` reports the tray
  1055. # the printer is feeding from *now*, and AMS filament backup rewrites it to
  1056. # the substitute tray when a spool runs dry. Read at completion it names
  1057. # the tray that finished the print, not the one the slicer assigned — the
  1058. # queue item's copy is the mapping the print was actually dispatched with.
  1059. if not slot_to_tray and archive_id:
  1060. queue_item = await _dispatch_queue_item()
  1061. if queue_item and queue_item.ams_mapping:
  1062. try:
  1063. slot_to_tray = json.loads(queue_item.ams_mapping)
  1064. mapping_source = "queue"
  1065. except (json.JSONDecodeError, TypeError):
  1066. pass
  1067. # 3. Try MQTT mapping field from printer state (universal, all print sources)
  1068. if not slot_to_tray:
  1069. state = printer_manager.get_status(printer_id)
  1070. raw_data = getattr(state, "raw_data", None) if state else None
  1071. if raw_data:
  1072. mqtt_mapping = raw_data.get("mapping")
  1073. decoded = _decode_mqtt_mapping(mqtt_mapping)
  1074. if decoded:
  1075. slot_to_tray = decoded
  1076. mapping_source = "mqtt"
  1077. # 4. Color-match 3MF filament slots to AMS trays (for printers without mapping field)
  1078. if not slot_to_tray:
  1079. state = printer_manager.get_status(printer_id)
  1080. raw_data = getattr(state, "raw_data", None) if state else None
  1081. if raw_data:
  1082. matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
  1083. if matched:
  1084. slot_to_tray = matched
  1085. mapping_source = "color_match"
  1086. logger.info(
  1087. "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
  1088. slot_to_tray,
  1089. mapping_source or "none",
  1090. )
  1091. # 5. For single-filament non-queue prints, use tray_now from printer state
  1092. # Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now
  1093. # > last_loaded_tray > vt_tray check
  1094. #
  1095. # tray_change_log evidence wins over slot_to_tray when present: if the
  1096. # printer fed from multiple trays mid-print (AMS auto-fallback when one
  1097. # spool runs out, #957), the slicer's mapping captured at print start
  1098. # is stale and needs to be replaced with per-layer split attribution.
  1099. nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
  1100. tray_now_override: int | None = None
  1101. tray_changes: list[tuple[int, int]] = [] # [(global_tray_id, layer_num), ...]
  1102. state = printer_manager.get_status(printer_id) if len(nonzero_slots) == 1 else None
  1103. if state is not None:
  1104. tray_changes = getattr(state, "tray_change_log", []) or []
  1105. elif len(nonzero_slots) > 1:
  1106. # Multi-material print: every filament change moves tray_now, so the
  1107. # log can't be read as "this slot moved to that tray" and splitting
  1108. # would attribute worse than the mapping does. Say so rather than
  1109. # silently dropping the evidence — a runout mid-print on a
  1110. # multi-material job still lands entirely on the mapped tray.
  1111. _multi_state = printer_manager.get_status(printer_id)
  1112. if len(getattr(_multi_state, "tray_change_log", []) or []) > 1:
  1113. logger.warning(
  1114. "[UsageTracker] 3MF: %d tray changes observed but %d filament slots used — "
  1115. "splitting needs a single slot, attributing by mapping alone (printer %d, archive %s)",
  1116. len(_multi_state.tray_change_log),
  1117. len(nonzero_slots),
  1118. printer_id,
  1119. archive_id,
  1120. )
  1121. if len(tray_changes) > 1:
  1122. # Multi-tray usage detected — splitting takes over regardless of slot_to_tray.
  1123. logger.info("[UsageTracker] 3MF: tray change log: %s (will split weight)", tray_changes)
  1124. elif not slot_to_tray and len(nonzero_slots) == 1:
  1125. if 0 <= tray_now_at_start <= 254:
  1126. tray_now_override = tray_now_at_start
  1127. logger.info("[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)", tray_now_at_start)
  1128. elif state and 0 <= state.tray_now <= 254:
  1129. tray_now_override = state.tray_now
  1130. logger.info("[UsageTracker] 3MF: using current tray_now=%d", state.tray_now)
  1131. elif state and 0 <= state.last_loaded_tray <= 253:
  1132. tray_now_override = state.last_loaded_tray
  1133. logger.info("[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)", state.last_loaded_tray)
  1134. elif state and state.tray_now == 255:
  1135. # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
  1136. vt_tray = state.raw_data.get("vt_tray") or []
  1137. if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
  1138. tray_now_override = state.tray_now
  1139. logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
  1140. if tray_now_override is None:
  1141. logger.info(
  1142. "[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",
  1143. tray_now_at_start,
  1144. state.tray_now if state else "N/A",
  1145. state.last_loaded_tray if state else "N/A",
  1146. )
  1147. # Scale factor for partial prints (failed/aborted)
  1148. if status == "completed":
  1149. scale = 1.0
  1150. else:
  1151. state = printer_manager.get_status(printer_id)
  1152. progress = state.progress if state else 0
  1153. # Firmware resets progress to 0 on cancel — use last valid progress captured during print
  1154. if progress <= 0 and last_progress > 0:
  1155. progress = last_progress
  1156. logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
  1157. scale = max(0.0, min(progress / 100.0, 1.0))
  1158. # Per-layer gcode accuracy for partial prints
  1159. layer_grams: dict[int, float] | None = None
  1160. if status != "completed":
  1161. state = printer_manager.get_status(printer_id)
  1162. current_layer = state.layer_num if state else 0
  1163. # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
  1164. if current_layer <= 0 and last_layer_num > 0:
  1165. current_layer = last_layer_num
  1166. logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
  1167. if current_layer > 0:
  1168. try:
  1169. from backend.app.utils.threemf_tools import (
  1170. extract_filament_properties_from_3mf,
  1171. extract_layer_filament_usage_from_3mf,
  1172. get_cumulative_usage_at_layer,
  1173. mm_to_grams,
  1174. )
  1175. layer_usage = extract_layer_filament_usage_from_3mf(file_path, plate_id)
  1176. if layer_usage:
  1177. cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
  1178. filament_props = extract_filament_properties_from_3mf(file_path)
  1179. layer_grams = {}
  1180. for filament_id, mm_used in cumulative_mm.items():
  1181. slot_id = filament_id + 1 # 0-based to 1-based
  1182. props = filament_props.get(slot_id, {})
  1183. density = props.get("density", 1.24)
  1184. diameter = props.get("diameter", 1.75)
  1185. layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
  1186. except Exception:
  1187. pass # Fall back to linear scaling
  1188. results = []
  1189. for usage in filament_usage:
  1190. slot_id = usage.get("slot_id", 0)
  1191. used_g = usage.get("used_g", 0)
  1192. if used_g <= 0:
  1193. continue
  1194. # --- Mid-print tray switch: split weight across trays ---
  1195. # Split math is shared with the Spoolman writer via
  1196. # ``utils.tray_split.compute_tray_split_grams`` (#1793) — both
  1197. # inventory backends must attribute segments identically or a
  1198. # user running dual-mode sees divergent totals.
  1199. if len(tray_changes) > 1:
  1200. # Compute total weight for this slot (same logic as normal path)
  1201. if layer_grams and slot_id in layer_grams:
  1202. total_weight = layer_grams[slot_id]
  1203. else:
  1204. total_weight = used_g * scale
  1205. if total_weight <= 0:
  1206. continue
  1207. # Extract per-layer gcode for segment splitting
  1208. split_layer_usage = None
  1209. split_props: dict = {}
  1210. try:
  1211. from backend.app.utils.threemf_tools import (
  1212. extract_filament_properties_from_3mf,
  1213. extract_layer_filament_usage_from_3mf,
  1214. )
  1215. split_layer_usage = extract_layer_filament_usage_from_3mf(file_path, plate_id)
  1216. filament_props = extract_filament_properties_from_3mf(file_path)
  1217. split_props = filament_props.get(slot_id, {})
  1218. except Exception:
  1219. pass # Fall back to linear splitting
  1220. from backend.app.utils.tray_split import compute_tray_split_grams
  1221. segments = compute_tray_split_grams(
  1222. tray_changes=tray_changes,
  1223. total_weight=total_weight,
  1224. slot_id=slot_id,
  1225. layer_usage=split_layer_usage,
  1226. density=split_props.get("density", 1.24),
  1227. diameter=split_props.get("diameter", 1.75),
  1228. total_layers=(state.total_layers if state else 0) or 0,
  1229. last_layer_num=last_layer_num,
  1230. )
  1231. for seg_idx, tray_global, segment_grams in segments:
  1232. if segment_grams <= 0:
  1233. continue
  1234. # Convert global tray ID to (ams_id, tray_id)
  1235. if tray_global >= 254:
  1236. seg_ams_id = 255
  1237. seg_tray_id = tray_global - 254
  1238. elif tray_global >= 128:
  1239. seg_ams_id = tray_global
  1240. seg_tray_id = 0
  1241. else:
  1242. seg_ams_id = tray_global // 4
  1243. seg_tray_id = tray_global % 4
  1244. seg_key = (seg_ams_id, seg_tray_id)
  1245. if seg_key in handled_trays:
  1246. continue
  1247. seg_start_layer = tray_changes[seg_idx][1]
  1248. is_last = seg_idx + 1 >= len(tray_changes)
  1249. logger.info(
  1250. "[UsageTracker] 3MF split: segment %d tray=%d (AMS%d-T%d) layers %d-%s -> %.1fg",
  1251. seg_idx,
  1252. tray_global,
  1253. seg_ams_id,
  1254. seg_tray_id,
  1255. seg_start_layer,
  1256. tray_changes[seg_idx + 1][1] if not is_last else "end",
  1257. segment_grams,
  1258. )
  1259. seg_spool_id = await _resolve_spool_id_for_tray(
  1260. printer_id=printer_id,
  1261. ams_id=seg_ams_id,
  1262. tray_id=seg_tray_id,
  1263. db=db,
  1264. spool_assignments_snapshot=spool_assignments,
  1265. print_started_at=print_started_at,
  1266. )
  1267. if seg_spool_id is None:
  1268. logger.info(
  1269. "[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
  1270. printer_id,
  1271. seg_ams_id,
  1272. seg_tray_id,
  1273. )
  1274. continue
  1275. spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))
  1276. spool = spool_result.scalar_one_or_none()
  1277. if not spool:
  1278. continue
  1279. spool.weight_used = (spool.weight_used or 0) + segment_grams
  1280. spool.last_used = datetime.now(timezone.utc)
  1281. percent = round(segment_grams / (spool.label_weight or 1000) * 100)
  1282. cost = None
  1283. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  1284. if cost_per_kg > 0:
  1285. cost = round((segment_grams / 1000.0) * cost_per_kg, 2)
  1286. history = SpoolUsageHistory(
  1287. spool_id=spool.id,
  1288. printer_id=printer_id,
  1289. print_name=print_name,
  1290. weight_used=round(segment_grams, 1),
  1291. percent_used=percent,
  1292. status=status,
  1293. cost=cost,
  1294. archive_id=archive_id,
  1295. )
  1296. db.add(history)
  1297. handled_trays.add(seg_key)
  1298. results.append(
  1299. {
  1300. "spool_id": spool.id,
  1301. "weight_used": round(segment_grams, 1),
  1302. "percent_used": percent,
  1303. "ams_id": seg_ams_id,
  1304. "tray_id": seg_tray_id,
  1305. "material": spool.material,
  1306. "cost": cost,
  1307. "slot_id": slot_id,
  1308. "color": _spool_color_to_hex(spool.rgba),
  1309. }
  1310. )
  1311. logger.info(
  1312. "[UsageTracker] Spool %d consumed %.1fg (3MF split seg%d) on printer %d AMS%d-T%d (%s)",
  1313. spool.id,
  1314. segment_grams,
  1315. seg_idx,
  1316. printer_id,
  1317. seg_ams_id,
  1318. seg_tray_id,
  1319. status,
  1320. )
  1321. continue # Skip normal single-tray processing for this slot
  1322. # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping
  1323. if tray_now_override is not None:
  1324. # Single-filament non-queue print: use actual tray from printer state
  1325. global_tray_id = tray_now_override
  1326. else:
  1327. # Explicit mapping (print command, MQTT, queue, color match)
  1328. global_tray_id = None
  1329. if slot_to_tray and slot_id <= len(slot_to_tray):
  1330. mapped = slot_to_tray[slot_id - 1]
  1331. if isinstance(mapped, int) and mapped >= 0:
  1332. global_tray_id = mapped
  1333. # Position-based default: sort available tray IDs so external spools (254/255)
  1334. # naturally follow standard AMS trays, matching slicer slot numbering.
  1335. #
  1336. # Filter out AMS slots that have no spool loaded (empty `tray_type`) —
  1337. # BambuStudio/OrcaSlicer compact the slot list when assigning filaments
  1338. # and don't expose empty AMS slots to the user, so the slicer's 3MF
  1339. # slot N maps to the Nth *loaded* tray, not the Nth physical position.
  1340. # Without this filter a "3 AMS slots loaded + 1 empty + external"
  1341. # layout routes the slicer's 4th filament to the empty AMS slot
  1342. # instead of the external (#1607), and the external's spool usage
  1343. # never gets recorded. vt_tray entries are already filtered the
  1344. # same way inside `build_ams_tray_lookup` (line 174 checks
  1345. # `tray_type`), so this just mirrors that for the AMS side.
  1346. if global_tray_id is None:
  1347. _state = printer_manager.get_status(printer_id)
  1348. _raw = getattr(_state, "raw_data", None) if _state else None
  1349. if _raw:
  1350. from backend.app.services.spoolman_tracking import build_ams_tray_lookup
  1351. _lookup = build_ams_tray_lookup(_raw)
  1352. available_trays = sorted(gid for gid, info in _lookup.items() if info.get("tray_type"))
  1353. if slot_id <= len(available_trays):
  1354. global_tray_id = available_trays[slot_id - 1]
  1355. # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)
  1356. if global_tray_id is None:
  1357. global_tray_id = slot_id - 1
  1358. if global_tray_id >= 254:
  1359. # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
  1360. ams_id = 255
  1361. tray_id = global_tray_id - 254
  1362. elif global_tray_id >= 128:
  1363. ams_id = global_tray_id
  1364. tray_id = 0
  1365. else:
  1366. ams_id = global_tray_id // 4
  1367. tray_id = global_tray_id % 4
  1368. logger.info(
  1369. "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",
  1370. slot_id,
  1371. global_tray_id,
  1372. ams_id,
  1373. tray_id,
  1374. used_g,
  1375. tray_now_override,
  1376. )
  1377. key = (ams_id, tray_id)
  1378. if key in handled_trays:
  1379. continue
  1380. spool_id = await _resolve_spool_id_for_tray(
  1381. printer_id=printer_id,
  1382. ams_id=ams_id,
  1383. tray_id=tray_id,
  1384. db=db,
  1385. spool_assignments_snapshot=spool_assignments,
  1386. print_started_at=print_started_at,
  1387. )
  1388. if spool_id is None:
  1389. logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
  1390. continue
  1391. # Load spool
  1392. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1393. spool = spool_result.scalar_one_or_none()
  1394. if not spool:
  1395. continue
  1396. # Use per-layer grams if available, otherwise linear scale
  1397. if layer_grams and slot_id in layer_grams:
  1398. weight_grams = layer_grams[slot_id]
  1399. else:
  1400. weight_grams = used_g * scale
  1401. if weight_grams <= 0:
  1402. continue
  1403. # Update spool
  1404. spool.weight_used = (spool.weight_used or 0) + weight_grams
  1405. spool.last_used = datetime.now(timezone.utc)
  1406. percent = round(weight_grams / (spool.label_weight or 1000) * 100)
  1407. # Calculate cost for this usage
  1408. cost = None
  1409. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  1410. if cost_per_kg > 0:
  1411. cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
  1412. # Insert usage history record
  1413. history = SpoolUsageHistory(
  1414. spool_id=spool.id,
  1415. printer_id=printer_id,
  1416. print_name=print_name,
  1417. weight_used=round(weight_grams, 1),
  1418. percent_used=percent,
  1419. status=status,
  1420. cost=cost,
  1421. archive_id=archive_id,
  1422. )
  1423. db.add(history)
  1424. handled_trays.add(key)
  1425. results.append(
  1426. {
  1427. "spool_id": spool.id,
  1428. "weight_used": round(weight_grams, 1),
  1429. "percent_used": percent,
  1430. "ams_id": ams_id,
  1431. "tray_id": tray_id,
  1432. "material": spool.material,
  1433. "cost": cost,
  1434. "slot_id": slot_id,
  1435. "color": _spool_color_to_hex(spool.rgba),
  1436. }
  1437. )
  1438. # Determine mapping source for debug logging
  1439. if tray_now_override is not None:
  1440. map_src = ", tray_now"
  1441. elif mapping_source:
  1442. map_src = f", {mapping_source}_map"
  1443. else:
  1444. map_src = ""
  1445. logger.info(
  1446. "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)",
  1447. spool.id,
  1448. weight_grams,
  1449. " per-layer" if (layer_grams and slot_id in layer_grams) else (f" scaled {scale:.0%}" if scale < 1 else ""),
  1450. map_src,
  1451. printer_id,
  1452. ams_id,
  1453. tray_id,
  1454. status,
  1455. )
  1456. # --- Adopt the matched inventory spools' colours for the archive (#1494) ---
  1457. # The archive's filament_color was set from the slicer's 3MF at creation
  1458. # time; now that every used slot has been resolved to an inventory spool,
  1459. # the curated spool colour is authoritative. Committed by the caller's
  1460. # `if results: await db.commit()`.
  1461. if archive is not None:
  1462. spool_colors = _archive_colors_from_spools(filament_usage, results)
  1463. if spool_colors:
  1464. joined = ",".join(spool_colors)
  1465. if joined != archive.filament_color:
  1466. logger.info(
  1467. "[UsageTracker] 3MF: archive %s filament_color %r -> %r (from inventory spools)",
  1468. archive_id,
  1469. archive.filament_color,
  1470. joined,
  1471. )
  1472. archive.filament_color = joined
  1473. # Adopt the matched spools' materials too (#2563) — a slot mapped to a
  1474. # differently-typed spool than it was sliced for otherwise records the
  1475. # sliced type in the archive, Print Log and material stats.
  1476. spool_types = _archive_types_from_spools(filament_usage, results)
  1477. if spool_types:
  1478. joined_types = ",".join(spool_types)
  1479. if joined_types != archive.filament_type:
  1480. logger.info(
  1481. "[UsageTracker] 3MF: archive %s filament_type %r -> %r (from inventory spools)",
  1482. archive_id,
  1483. archive.filament_type,
  1484. joined_types,
  1485. )
  1486. archive.filament_type = joined_types
  1487. return results