usage_tracker.py 96 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233
  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. import re
  11. from dataclasses import dataclass, field
  12. from datetime import datetime, timezone
  13. from sqlalchemy import func, select, update
  14. from sqlalchemy.ext.asyncio import AsyncSession
  15. from backend.app.models.spool import Spool
  16. from backend.app.models.spool_assignment import SpoolAssignment
  17. from backend.app.models.spool_usage_history import SpoolUsageHistory
  18. from backend.app.services import spool_swaps
  19. logger = logging.getLogger(__name__)
  20. def has_second_external_holder(raw_data: dict | None) -> bool:
  21. """Whether the printer has a second external holder (vt_tray 255).
  22. ``tray_now`` 255 means "nothing loaded" on a printer with one external
  23. holder, and the right-hand external spool on one with two (H2D, X2D),
  24. which lists it in ``vt_tray``. Both inventory modes read it this way
  25. (#3405).
  26. """
  27. for vt in (raw_data or {}).get("vt_tray") or []:
  28. if not isinstance(vt, dict):
  29. continue
  30. try:
  31. if int(vt.get("id", 0)) == 255:
  32. return True
  33. except (TypeError, ValueError):
  34. continue
  35. return False
  36. def mapping_names_no_used_tray(slot_to_tray: list | None, filament_usage: list[dict] | None) -> bool:
  37. """Whether a slot-to-tray mapping names no tray for any slot the print used.
  38. Such a mapping carries no evidence at all: typically a flat ams_mapping
  39. whose external spool is -1 and came without an ams_mapping2 to resolve it
  40. (#3166). Both inventory modes drop it, so the printer's own readings
  41. (``tray_now``, the tray-change log) decide which tray fed the print
  42. instead of every slot reading as unfed (#3405).
  43. """
  44. if not slot_to_tray:
  45. return False
  46. used_slots = [u.get("slot_id", 0) for u in filament_usage or [] if u.get("used_g", 0) > 0]
  47. return bool(used_slots) and all(
  48. not (0 < s <= len(slot_to_tray) and isinstance(slot_to_tray[s - 1], int) and slot_to_tray[s - 1] >= 0)
  49. for s in used_slots
  50. )
  51. def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
  52. """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
  53. The printer's MQTT mapping field is an array indexed by slicer filament slot
  54. (0-based). Each value uses snow encoding: ams_hw_id * 256 + local_slot.
  55. 65535 means unmapped.
  56. Returns a list of bambuddy global tray IDs (or -1 for unmapped), or None if
  57. no valid mappings found.
  58. """
  59. if not isinstance(mapping_raw, list) or not mapping_raw:
  60. return None
  61. result = []
  62. for value in mapping_raw:
  63. if not isinstance(value, int) or value >= 65535:
  64. result.append(-1)
  65. continue
  66. ams_hw_id = value >> 8
  67. slot = value & 0xFF
  68. if 0 <= ams_hw_id <= 3:
  69. # Regular AMS: sequential global ID
  70. result.append(ams_hw_id * 4 + (slot & 0x03))
  71. elif 128 <= ams_hw_id <= 135:
  72. # AMS-HT: global ID is the hardware ID (one slot per unit)
  73. result.append(ams_hw_id)
  74. elif ams_hw_id in (254, 255):
  75. # External spool
  76. result.append(254 if slot != 255 else 255)
  77. else:
  78. result.append(-1)
  79. # Only return if at least one valid mapping exists
  80. if all(v < 0 for v in result):
  81. return None
  82. return result
  83. def _spool_color_to_hex(rgba: str | None) -> str | None:
  84. """Normalise a ``Spool.rgba`` value (``RRGGBBAA`` hex, no ``#``) to the
  85. ``#RRGGBB`` form archives store in ``filament_color``.
  86. Alpha is dropped — the archive colour list and the Color Distribution
  87. graph treat filament colour as opaque. Returns ``None`` for a missing or
  88. too-short value so the caller can fall back to the 3MF colour.
  89. """
  90. if not rgba:
  91. return None
  92. h = rgba.strip().lstrip("#")
  93. if len(h) < 6:
  94. return None
  95. return "#" + h[:6].upper()
  96. def _archive_colors_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
  97. """Slot-ordered, de-duplicated hex colours for an archive's ``filament_color``,
  98. taken from the inventory spools that actually fed the print (#1494).
  99. The slicer's 3MF carries its own ``filament_colour`` per slot — a value
  100. picked independently of the colour the user curates on the matched
  101. inventory spool. So an archive printed from a ``#000000`` inventory spool
  102. would otherwise show the slicer's near-black ``#161616``. Once usage
  103. tracking has resolved the used slots to spools, the spool colours are the
  104. authoritative source and replace the 3MF values.
  105. Returns ``None`` — leave the 3MF colour untouched — unless *every* slot
  106. with non-zero usage was matched to a spool that carries a colour. A
  107. partial rewrite would silently drop the unmatched slots' colours from the
  108. archive (and the Color Distribution graph), so it is all-or-nothing.
  109. """
  110. 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}
  111. if not used_slots:
  112. return None
  113. slot_color: dict[int, str] = {}
  114. for r in results:
  115. slot_id = r.get("slot_id")
  116. color = r.get("color")
  117. if slot_id is not None and color:
  118. slot_color.setdefault(slot_id, color)
  119. if not used_slots.issubset(slot_color):
  120. return None
  121. ordered: list[str] = []
  122. for slot_id in sorted(used_slots):
  123. color = slot_color[slot_id]
  124. if color not in ordered:
  125. ordered.append(color)
  126. return ordered
  127. def _archive_types_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
  128. """Slot-ordered, de-duplicated materials for an archive's ``filament_type``,
  129. taken from the inventory spools that actually fed the print (#2563).
  130. The slicer's 3MF records the filament type it was *sliced for*. When the
  131. user manually maps a slot to a differently-typed loaded spool in the Print
  132. dialog — a PLA slice routed to the only loaded PETG slot — that sliced type
  133. misclassifies the run in the archive card, the Print Log and the material
  134. statistics, even though the deduction correctly hit the PETG spool. Once
  135. usage tracking has resolved every used slot to an inventory spool, the
  136. spool's declared material is the authoritative record of what was consumed,
  137. the same reasoning that already adopts the spool colour (#1494).
  138. Returns ``None`` — leave the 3MF type untouched — unless *every* slot with
  139. non-zero usage was matched to a spool that carries a material. All-or-
  140. nothing, exactly like ``_archive_colors_from_spools``: a partial rewrite
  141. would silently drop the unmatched slots' types from the archive (and the
  142. material stats).
  143. """
  144. 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}
  145. if not used_slots:
  146. return None
  147. slot_material: dict[int, str] = {}
  148. for r in results:
  149. slot_id = r.get("slot_id")
  150. material = (r.get("material") or "").strip()
  151. if slot_id is not None and material:
  152. slot_material.setdefault(slot_id, material)
  153. if not used_slots.issubset(slot_material):
  154. return None
  155. ordered: list[str] = []
  156. for slot_id in sorted(used_slots):
  157. material = slot_material[slot_id]
  158. if material not in ordered:
  159. ordered.append(material)
  160. return ordered
  161. def _match_slots_by_color(
  162. filament_usage: list[dict],
  163. ams_raw: dict | list | None,
  164. ) -> list[int] | None:
  165. """Match 3MF filament slots to AMS trays by color.
  166. Fallback mapping for printers that don't provide the MQTT mapping field
  167. or request topic subscription (e.g. A1, A1 Mini, P1S, P2S).
  168. Compares the 3MF slicer filament color (per slot) against each AMS tray's
  169. color to find a unique match. Only returns a mapping if every used slot
  170. matches exactly one tray (no ambiguity).
  171. Args:
  172. filament_usage: List of 3MF slot dicts with 'slot_id', 'color', 'type'
  173. ams_raw: raw_data["ams"] dict or list from printer state
  174. Returns:
  175. List of global tray IDs indexed by slicer slot (0-based), or None.
  176. """
  177. if not filament_usage or not ams_raw:
  178. return None
  179. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  180. if not ams_data:
  181. return None
  182. # Build map of normalized color → list of global tray IDs
  183. color_to_trays: dict[str, list[int]] = {}
  184. for ams_unit in ams_data:
  185. ams_id = int(ams_unit.get("id", 0))
  186. for tray in ams_unit.get("tray", []):
  187. tray_id = int(tray.get("id", 0))
  188. tray_color = tray.get("tray_color", "")
  189. tray_type = tray.get("tray_type", "")
  190. if not tray_color or not tray_type:
  191. continue
  192. # Normalize AMS color: strip alpha (last 2 chars), lowercase
  193. norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
  194. if ams_id >= 128:
  195. global_id = ams_id # AMS-HT
  196. else:
  197. global_id = ams_id * 4 + tray_id
  198. color_to_trays.setdefault(norm, []).append(global_id)
  199. if not color_to_trays:
  200. return None
  201. # Find max slot_id to size the result array
  202. max_slot = max(u.get("slot_id", 0) for u in filament_usage)
  203. if max_slot <= 0:
  204. return None
  205. result = [-1] * max_slot
  206. used_trays: set[int] = set()
  207. for usage in filament_usage:
  208. slot_id = usage.get("slot_id", 0)
  209. if slot_id <= 0:
  210. continue
  211. slot_color = usage.get("color", "").lstrip("#").lower()
  212. if len(slot_color) < 6:
  213. return None # Can't match without a valid color
  214. slot_color = slot_color[:6] # Strip alpha if present
  215. candidates = color_to_trays.get(slot_color, [])
  216. # Filter out trays already claimed by another slot
  217. available = [t for t in candidates if t not in used_trays]
  218. if len(available) != 1:
  219. # Ambiguous (multiple trays with same color) or no match
  220. return None
  221. result[slot_id - 1] = available[0]
  222. used_trays.add(available[0])
  223. # Only return if at least one valid mapping exists
  224. if all(v < 0 for v in result):
  225. return None
  226. logger.info("[UsageTracker] Color-matched slot_to_tray: %s", result)
  227. return result
  228. @dataclass
  229. class PrintSession:
  230. printer_id: int
  231. print_name: str
  232. started_at: datetime
  233. tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
  234. # tray_now at print start (correct value, unlike at completion where it's 255)
  235. tray_now_at_start: int = -1
  236. # Snapshot of spool assignments at print start: {(ams_id, tray_id): spool_id}
  237. # Prevents usage loss when on_ams_change unlinks a spool mid-print
  238. spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
  239. # AMS mapping from print command (captured at start, needed when auto-archive is off)
  240. ams_mapping: list[int] | None = None
  241. # Queue item's plate_id when this print is a multi-plate 3MF dispatched for a
  242. # single plate (#1697). None for non-queue prints — the file's first/only plate
  243. # is the default and the 3MF parser already returns the full file in that case.
  244. plate_id: int | None = None
  245. # Module-level storage, keyed by printer_id. Mirrored to the
  246. # ``active_print_sessions`` table so a restart mid-print doesn't lose the
  247. # context — see ``persist_session`` / ``restore_session``.
  248. _active_sessions: dict[int, PrintSession] = {}
  249. # Printers whose finished print is being closed out. on_print_complete takes the
  250. # session first and charges the usage several awaits later; the printer already
  251. # reports FINISH, so without this mark the AMS remain% weight sync would run in
  252. # between and the print would be charged twice (WU-3).
  253. _completing: set[int] = set()
  254. def mark_completing(printer_id: int) -> None:
  255. """The completion path's first step: hold the weight sync off until the
  256. usage is booked (discard_session) or the completion ends."""
  257. _completing.add(printer_id)
  258. def done_completing(printer_id: int) -> None:
  259. _completing.discard(printer_id)
  260. def is_completing(printer_id: int) -> bool:
  261. """The printer's finished print is being closed out (on_print_complete runs)."""
  262. return printer_id in _completing
  263. def printer_is_charging(printer_id: int) -> bool:
  264. """A print's usage is still to be charged: it runs, or it ended and its
  265. completion has not booked it yet. The AMS remain% sync leaves the
  266. printer's spools alone meanwhile (#880)."""
  267. return printer_id in _active_sessions or printer_id in _completing
  268. # Serialises the read-modify-write on the persisted tray-change log, per printer.
  269. _tray_change_locks: dict[int, asyncio.Lock] = {}
  270. def _tray_key_to_str(key: tuple[int, int]) -> str:
  271. return f"{key[0]}-{key[1]}"
  272. def _tray_key_from_str(key: str) -> tuple[int, int] | None:
  273. ams_str, _, tray_str = key.partition("-")
  274. try:
  275. return int(ams_str), int(tray_str)
  276. except ValueError:
  277. return None
  278. def _tray_map_to_json(mapping: dict[tuple[int, int], int]) -> dict[str, int]:
  279. return {_tray_key_to_str(k): v for k, v in mapping.items()}
  280. def _tray_map_from_json(mapping: dict | None) -> dict[tuple[int, int], int]:
  281. result: dict[tuple[int, int], int] = {}
  282. for raw_key, value in (mapping or {}).items():
  283. key = _tray_key_from_str(str(raw_key))
  284. if key is not None and isinstance(value, int):
  285. result[key] = value
  286. return result
  287. async def _charge_spool(db: AsyncSession, spool: Spool, grams: float) -> bool:
  288. """Add *grams* to the spool's usage in the database itself (WU-4).
  289. The spool was loaded several awaits earlier; a weight set by hand or
  290. weighed on the scale since then would be lost to a write of the value
  291. computed from that copy. The loaded copy is set to the stored result: from
  292. this statement until the commit, the transaction holds the row (the write
  293. lock on SQLite), so writing it back on flush changes nothing.
  294. False when the spool was deleted meanwhile: the caller skips it, since a
  295. usage row for it would break its foreign key on PostgreSQL and roll back
  296. the whole completion.
  297. """
  298. now = datetime.now(timezone.utc)
  299. stored = await db.scalar(
  300. update(Spool)
  301. .where(Spool.id == spool.id)
  302. .values(weight_used=func.coalesce(Spool.weight_used, 0) + grams, last_used=now)
  303. .returning(Spool.weight_used)
  304. .execution_options(synchronize_session=False)
  305. )
  306. if stored is None:
  307. return False
  308. spool.weight_used = stored
  309. spool.last_used = now
  310. return True
  311. async def persist_session(
  312. db: AsyncSession,
  313. session: PrintSession,
  314. tray_change_log: list | None = None,
  315. spool_change_log: list | None = None,
  316. ) -> None:
  317. """Mirror ``session`` into ``active_print_sessions`` for restart recovery.
  318. Overwrites any existing row for the printer: a printer runs one print at a
  319. time, and a row left behind by a completion we never saw must not outlive
  320. the next print start.
  321. """
  322. from backend.app.models.active_print_session import ActivePrintSession
  323. row = await db.get(ActivePrintSession, session.printer_id)
  324. if row is None:
  325. row = ActivePrintSession(printer_id=session.printer_id)
  326. db.add(row)
  327. row.print_name = session.print_name or ""
  328. row.started_at = session.started_at.replace(tzinfo=None)
  329. row.tray_now_at_start = session.tray_now_at_start
  330. row.plate_id = session.plate_id
  331. row.ams_mapping = list(session.ams_mapping) if session.ams_mapping else None
  332. row.spool_assignments = _tray_map_to_json(session.spool_assignments) or None
  333. row.tray_remain_start = _tray_map_to_json(session.tray_remain_start) or None
  334. row.tray_change_log = [list(entry) for entry in (tray_change_log or [])] or None
  335. row.spool_change_log = list(spool_change_log or []) or None
  336. await db.commit()
  337. async def record_tray_change(db: AsyncSession, printer_id: int, tray_global: int, layer_num: int) -> None:
  338. """Append one tray change to the persisted log.
  339. No-op when no print-start row exists — a tray change outside a tracked
  340. print has nothing to attribute.
  341. """
  342. from backend.app.models.active_print_session import ActivePrintSession
  343. # Read-modify-write on a JSON column: two changes close together (a runout
  344. # parks the extruder and the backup tray loads moments later) would
  345. # otherwise race and drop a segment boundary.
  346. async with _tray_change_locks.setdefault(printer_id, asyncio.Lock()):
  347. row = await db.get(ActivePrintSession, printer_id)
  348. if row is None:
  349. return
  350. log = [list(entry) for entry in (row.tray_change_log or [])]
  351. entry = [tray_global, layer_num]
  352. if log and log[-1] == entry:
  353. # print-start seeds the log from PrinterState, which may already
  354. # hold a change this callback is also reporting.
  355. return
  356. log.append(entry)
  357. row.tray_change_log = log
  358. await db.commit()
  359. async def get_persisted_print_name(db: AsyncSession, printer_id: int) -> str | None:
  360. """Print name on the persisted row, for identity-checking a restored session."""
  361. from backend.app.models.active_print_session import ActivePrintSession
  362. row = await db.get(ActivePrintSession, printer_id)
  363. return row.print_name if row is not None else None
  364. async def get_persisted_spool_change_log(db: AsyncSession, printer_id: int) -> list[dict]:
  365. """The mid-print spool assignments on the persisted row (services.spool_swaps)."""
  366. from backend.app.models.active_print_session import ActivePrintSession
  367. row = await db.get(ActivePrintSession, printer_id)
  368. return [item for item in (row.spool_change_log or []) if isinstance(item, dict)] if row is not None else []
  369. async def get_persisted_ams_mapping(db: AsyncSession, printer_id: int) -> list[int] | None:
  370. """AMS mapping on the persisted row: the one captured from the print command."""
  371. from backend.app.models.active_print_session import ActivePrintSession
  372. row = await db.get(ActivePrintSession, printer_id)
  373. return list(row.ams_mapping) if row is not None and row.ams_mapping else None
  374. async def restore_session(db: AsyncSession, printer_id: int, register_active: bool = True) -> list[list[int]] | None:
  375. """Rebuild the in-memory session for ``printer_id`` from the persisted row.
  376. Returns the persisted tray-change log so the caller can put it back on
  377. ``PrinterState``, or None when there is nothing to restore.
  378. ``register_active=False`` returns the log without publishing the session to
  379. ``_active_sessions`` — for Spoolman users, who need the tray-change log
  380. restored but whose remain%-sync must not be suppressed by it (see
  381. ``on_print_start``).
  382. """
  383. from backend.app.models.active_print_session import ActivePrintSession
  384. row = await db.get(ActivePrintSession, printer_id)
  385. if row is None:
  386. return None
  387. started_at = row.started_at
  388. if started_at.tzinfo is None:
  389. started_at = started_at.replace(tzinfo=timezone.utc)
  390. session = PrintSession(
  391. printer_id=printer_id,
  392. print_name=row.print_name or "",
  393. started_at=started_at,
  394. tray_remain_start=_tray_map_from_json(row.tray_remain_start),
  395. tray_now_at_start=row.tray_now_at_start,
  396. spool_assignments=_tray_map_from_json(row.spool_assignments),
  397. ams_mapping=list(row.ams_mapping) if row.ams_mapping else None,
  398. plate_id=row.plate_id,
  399. )
  400. if register_active:
  401. _active_sessions[printer_id] = session
  402. log = [list(entry) for entry in (row.tray_change_log or [])]
  403. logger.info(
  404. "[UsageTracker] Restored print session for printer %d: plate_id=%s, ams_mapping=%s, "
  405. "%d assignments, tray_change_log=%s",
  406. printer_id,
  407. row.plate_id,
  408. row.ams_mapping,
  409. len(row.spool_assignments or {}),
  410. log,
  411. )
  412. return log
  413. async def clear_persisted_session(db: AsyncSession, printer_id: int) -> None:
  414. """Drop the persisted print-start row once the print is closed out."""
  415. from backend.app.models.active_print_session import ActivePrintSession
  416. row = await db.get(ActivePrintSession, printer_id)
  417. if row is not None:
  418. await db.delete(row)
  419. await db.commit()
  420. def forget_printer(printer_id: int) -> None:
  421. """A deleted printer's print-start context in memory (the row goes with the printer)."""
  422. _active_sessions.pop(printer_id, None)
  423. _completing.discard(printer_id)
  424. _tray_change_locks.pop(printer_id, None)
  425. async def discard_session(db: AsyncSession, printer_id: int) -> None:
  426. """Forget a printer's print-start context, in memory and on disk.
  427. The completion path calls this for every print, including the ones whose
  428. usage Spoolman owns: the context is captured for both backends, but only
  429. the internal tracker's ``on_print_complete`` consumes (and pops) it.
  430. """
  431. _active_sessions.pop(printer_id, None)
  432. _completing.discard(printer_id)
  433. _tray_change_locks.pop(printer_id, None)
  434. await clear_persisted_session(db, printer_id)
  435. def _to_epoch_seconds(value: datetime | None) -> float | None:
  436. """Convert datetime to epoch seconds, assuming UTC for naive values."""
  437. if value is None:
  438. return None
  439. dt = value
  440. if dt.tzinfo is None:
  441. dt = dt.replace(tzinfo=timezone.utc)
  442. return dt.timestamp()
  443. async def _resolve_spool_id_for_tray(
  444. printer_id: int,
  445. ams_id: int,
  446. tray_id: int,
  447. db: AsyncSession,
  448. spool_assignments_snapshot: dict[tuple[int, int], int] | None = None,
  449. print_started_at: datetime | None = None,
  450. ) -> int | None:
  451. """Resolve spool ID for a tray with safe support for mid-print reassignment.
  452. Resolution order:
  453. 1. If snapshot exists and live assignment changed *during this print*, use live spool.
  454. 2. Otherwise use snapshot spool when available.
  455. 3. Fall back to live assignment.
  456. """
  457. key = (ams_id, tray_id)
  458. snapshot_spool_id = spool_assignments_snapshot.get(key) if spool_assignments_snapshot else None
  459. # Backward-compatible fast path: if we have a snapshot but no print-start
  460. # timestamp, preserve legacy behavior and avoid extra DB lookups.
  461. if snapshot_spool_id is not None and print_started_at is None:
  462. return snapshot_spool_id
  463. result = await db.execute(
  464. select(SpoolAssignment).where(
  465. SpoolAssignment.printer_id == printer_id,
  466. SpoolAssignment.ams_id == ams_id,
  467. SpoolAssignment.tray_id == tray_id,
  468. )
  469. )
  470. live_assignment = result.scalar_one_or_none()
  471. if snapshot_spool_id is not None:
  472. if live_assignment and live_assignment.spool_id != snapshot_spool_id:
  473. live_created_ts = _to_epoch_seconds(getattr(live_assignment, "created_at", None))
  474. started_ts = _to_epoch_seconds(print_started_at)
  475. if live_created_ts is not None and started_ts is not None and live_created_ts >= started_ts:
  476. logger.info(
  477. "[UsageTracker] Assignment changed during print for printer %d AMS%d-T%d: snapshot spool %d -> live spool %d",
  478. printer_id,
  479. ams_id,
  480. tray_id,
  481. snapshot_spool_id,
  482. live_assignment.spool_id,
  483. )
  484. return live_assignment.spool_id
  485. return snapshot_spool_id
  486. if live_assignment:
  487. return live_assignment.spool_id
  488. return None
  489. async def on_print_start(
  490. printer_id: int,
  491. data: dict,
  492. printer_manager,
  493. db: AsyncSession | None = None,
  494. spoolman_owns_usage: bool = False,
  495. ) -> None:
  496. """Capture AMS tray remain% and spool assignments at print start.
  497. The capture runs for both inventory backends — the persisted row carries
  498. the tray-change log, which is the only record of which spool fed which
  499. layers when AMS Filament Backup swaps trays, and Spoolman's own durable
  500. row (#1820) does not hold it.
  501. ``spoolman_owns_usage`` keeps the in-memory session out of
  502. ``_active_sessions`` when Spoolman is writing the usage. That dict doubles
  503. as ``on_ams_change``'s "a print is running, so skip the remain%-based
  504. weight sync because the internal tracker will deduct precisely" flag
  505. (#880); registering a session the internal tracker will never complete
  506. would suppress a sync those users still need.
  507. """
  508. # A new print: whatever the last completion left marked is over.
  509. _completing.discard(printer_id)
  510. state = printer_manager.get_status(printer_id)
  511. if not state or not state.raw_data:
  512. logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
  513. return
  514. ams_raw = state.raw_data.get("ams", [])
  515. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  516. tray_remain_start: dict[tuple[int, int], int] = {}
  517. skipped_invalid: list[str] = []
  518. for ams_unit in ams_data:
  519. ams_id = int(ams_unit.get("id", 0))
  520. for tray in ams_unit.get("tray", []):
  521. tray_id = int(tray.get("id", 0))
  522. remain = tray.get("remain", -1)
  523. if isinstance(remain, int) and 0 <= remain <= 100:
  524. tray_remain_start[(ams_id, tray_id)] = remain
  525. else:
  526. skipped_invalid.append(f"AMS{ams_id}-T{tray_id}(remain={remain})")
  527. # Also capture VT (external) tray remain% — these are separate from AMS units
  528. vt_tray_raw = state.raw_data.get("vt_tray") or []
  529. if isinstance(vt_tray_raw, dict):
  530. vt_tray_raw = [vt_tray_raw]
  531. for vt in vt_tray_raw:
  532. if not isinstance(vt, dict):
  533. continue
  534. vt_id = int(vt.get("id", 254))
  535. # VT tray id 254 → (ams_id=255, tray_id=0), id 255 → (ams_id=255, tray_id=1)
  536. vt_tray_id = vt_id - 254
  537. remain = vt.get("remain", -1)
  538. if isinstance(remain, int) and 0 <= remain <= 100:
  539. tray_remain_start[(255, vt_tray_id)] = remain
  540. else:
  541. skipped_invalid.append(f"VT{vt_id}(remain={remain})")
  542. if skipped_invalid:
  543. logger.info(
  544. "[UsageTracker] Skipped trays with invalid remain%% for printer %d: %s",
  545. printer_id,
  546. ", ".join(skipped_invalid),
  547. )
  548. if not ams_data and not vt_tray_raw:
  549. logger.debug("[UsageTracker] No AMS or VT tray data for printer %d, skipping", printer_id)
  550. return
  551. print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
  552. # Capture tray_now at print start (reliable, unlike at completion where it's 255)
  553. tray_now_at_start = state.tray_now if state else -1
  554. # --- Diagnostic logging: dump mapping-related MQTT fields at print start ---
  555. # This helps us understand what each printer model reports for slot-to-tray mapping.
  556. mapping_field = state.raw_data.get("mapping")
  557. logger.info(
  558. "[UsageTracker] PRINT START printer %d: mapping=%s, tray_now=%d, last_loaded_tray=%s",
  559. printer_id,
  560. mapping_field,
  561. tray_now_at_start,
  562. getattr(state, "last_loaded_tray", "N/A"),
  563. )
  564. # Log all raw_data keys containing "map" or "ams" for discovery
  565. map_keys = {k: state.raw_data[k] for k in state.raw_data if "map" in k.lower()}
  566. if map_keys:
  567. logger.info("[UsageTracker] PRINT START printer %d: mapping-related keys: %s", printer_id, map_keys)
  568. # Log per-tray summary: tray_now, tray_tar, tray_type, tray_color for each slot
  569. for ams_unit in ams_data:
  570. ams_id = int(ams_unit.get("id", 0))
  571. tray_summary = []
  572. for tray in ams_unit.get("tray", []):
  573. tray_summary.append(
  574. f"T{tray.get('id', '?')}(type={tray.get('tray_type', '')}, "
  575. f"color={tray.get('tray_color', '')}, "
  576. f"now={ams_raw.get('tray_now', '?') if isinstance(ams_raw, dict) else '?'}, "
  577. f"tar={ams_raw.get('tray_tar', '?') if isinstance(ams_raw, dict) else '?'})"
  578. )
  579. logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
  580. # Snapshot spool assignments so usage isn't lost if on_ams_change unlinks mid-print
  581. spool_assignments: dict[tuple[int, int], int] = {}
  582. if db:
  583. assign_result = await db.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer_id))
  584. for assignment in assign_result.scalars().all():
  585. spool_assignments[(assignment.ams_id, assignment.tray_id)] = assignment.spool_id
  586. if spool_assignments:
  587. logger.info(
  588. "[UsageTracker] Snapshotted %d spool assignments for printer %d: %s",
  589. len(spool_assignments),
  590. printer_id,
  591. {f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
  592. )
  593. # The spool each assigned tray starts the print with, in the backend that
  594. # writes the usage: a spool swapped on a tray mid-print is split against it
  595. # (#3325).
  596. if spoolman_owns_usage and db:
  597. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  598. spoolman_slots = {
  599. (row.ams_id, row.tray_id): row.spoolman_spool_id
  600. for row in (
  601. await db.execute(select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
  602. ).scalars()
  603. }
  604. state.spool_change_log = spool_swaps.start_entries(spool_swaps.SPOOLMAN, spoolman_slots)
  605. else:
  606. state.spool_change_log = spool_swaps.start_entries(spool_swaps.INVENTORY, spool_assignments)
  607. # Capture the queue item's plate_id so 3MF parsing at completion is scoped to
  608. # the plate that actually ran, not the whole multi-plate file (#1697).
  609. plate_id: int | None = None
  610. if db:
  611. from backend.app.models.print_queue import PrintQueueItem
  612. queue_result = await db.execute(
  613. select(PrintQueueItem)
  614. .where(PrintQueueItem.printer_id == printer_id)
  615. .where(PrintQueueItem.status == "printing")
  616. )
  617. queue_item = queue_result.scalars().first()
  618. if queue_item is not None:
  619. plate_id = queue_item.plate_id
  620. # Always create session (even without valid remain data) so print_name
  621. # is available at completion for 3MF-based tracking
  622. session = PrintSession(
  623. printer_id=printer_id,
  624. print_name=print_name,
  625. started_at=datetime.now(timezone.utc),
  626. tray_remain_start=tray_remain_start,
  627. tray_now_at_start=tray_now_at_start,
  628. spool_assignments=spool_assignments,
  629. ams_mapping=data.get("ams_mapping"),
  630. plate_id=plate_id,
  631. )
  632. if spoolman_owns_usage:
  633. _active_sessions.pop(printer_id, None)
  634. else:
  635. _active_sessions[printer_id] = session
  636. # Mirror to the DB so a restart mid-print doesn't lose the context. The
  637. # tray-change log has already been cleared and seeded with the starting
  638. # tray by bambu_mqtt before this callback fires.
  639. if db:
  640. try:
  641. await persist_session(
  642. db,
  643. session,
  644. getattr(state, "tray_change_log", None),
  645. getattr(state, "spool_change_log", None),
  646. )
  647. except Exception:
  648. logger.exception("[UsageTracker] Failed to persist print session for printer %d", printer_id)
  649. if tray_remain_start:
  650. logger.info(
  651. "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
  652. printer_id,
  653. len(tray_remain_start),
  654. {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
  655. )
  656. else:
  657. logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
  658. async def on_print_complete(
  659. printer_id: int,
  660. data: dict,
  661. printer_manager,
  662. db: AsyncSession,
  663. archive_id: int | None = None,
  664. ams_mapping: list[int] | None = None,
  665. ) -> list[dict]:
  666. """Compute consumption deltas and update spool weight_used/last_used.
  667. Uses two tracking strategies in priority order:
  668. 1. 3MF per-filament estimates (primary) — precise slicer data for all spools
  669. 2. AMS remain% delta (fallback) — only for trays not already handled by 3MF
  670. Returns a list of dicts describing what was logged (for WebSocket broadcast).
  671. """
  672. from sqlalchemy import select
  673. from backend.app.models.spool_usage_history import SpoolUsageHistory
  674. session = _active_sessions.pop(printer_id, None)
  675. if session is None:
  676. # Restart mid-print: the in-memory session is gone but the print-start
  677. # row survived. Without this the completion path loses the plate, the
  678. # dispatched mapping and the assignment snapshot, and attributes the
  679. # whole print to whichever tray happened to finish it.
  680. try:
  681. await restore_session(db, printer_id)
  682. except Exception:
  683. logger.exception("[UsageTracker] Failed to restore print session for printer %d", printer_id)
  684. session = _active_sessions.pop(printer_id, None)
  685. status = data.get("status", "completed")
  686. results = []
  687. handled_trays: set[tuple[int, int]] = set()
  688. # Fetch default filament cost from settings for fallback
  689. from backend.app.services.print_cost_estimate import default_cost_per_kg
  690. default_filament_cost = await default_cost_per_kg(db)
  691. # Fall back to ams_mapping captured at print start (needed when auto-archive is off
  692. # and the caller can't retrieve the mapping from _print_ams_mappings without archive_id)
  693. if not ams_mapping and session and session.ams_mapping:
  694. ams_mapping = session.ams_mapping
  695. logger.info(
  696. "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s",
  697. printer_id,
  698. archive_id,
  699. "yes" if session else "no",
  700. ams_mapping,
  701. )
  702. # --- Diagnostic logging: dump mapping-related MQTT fields at print completion ---
  703. state = printer_manager.get_status(printer_id)
  704. if state and state.raw_data:
  705. logger.info(
  706. "[UsageTracker] PRINT COMPLETE printer %d: mapping=%s, tray_now=%s, last_loaded_tray=%s",
  707. printer_id,
  708. state.raw_data.get("mapping"),
  709. state.tray_now,
  710. getattr(state, "last_loaded_tray", "N/A"),
  711. )
  712. # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
  713. print_name = (
  714. (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
  715. )
  716. # When auto-archive is disabled (archive_id=None), try to find a 3MF by filename
  717. # from the library or previous archives so we can still track filament usage.
  718. threemf_path = None
  719. if not archive_id:
  720. from backend.app.core.config import settings as app_settings
  721. search_filename = data.get("filename") or data.get("subtask_name") or (session.print_name if session else "")
  722. if search_filename:
  723. threemf_path = await _find_3mf_by_filename(
  724. printer_id,
  725. search_filename,
  726. db,
  727. app_settings.base_dir,
  728. print_name=data.get("subtask_name") or (session.print_name if session else None),
  729. )
  730. if archive_id or threemf_path:
  731. threemf_results = await _track_from_3mf(
  732. printer_id,
  733. archive_id,
  734. status,
  735. print_name,
  736. handled_trays,
  737. printer_manager,
  738. db,
  739. ams_mapping=ams_mapping,
  740. tray_now_at_start=session.tray_now_at_start if session else -1,
  741. last_progress=data.get("last_progress", 0.0),
  742. last_layer_num=data.get("last_layer_num", 0),
  743. default_filament_cost=default_filament_cost,
  744. spool_assignments=session.spool_assignments if session else None,
  745. print_started_at=session.started_at if session else None,
  746. threemf_path=threemf_path,
  747. plate_id=session.plate_id if session else None,
  748. )
  749. results.extend(threemf_results)
  750. # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---
  751. if session and session.tray_remain_start:
  752. state = printer_manager.get_status(printer_id)
  753. if state and state.raw_data:
  754. ams_raw = state.raw_data.get("ams", [])
  755. ams_data = (
  756. ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  757. )
  758. # Build set of trays actually involved in this print (#1269).
  759. # Without this guard, swapping a spool in an UNUSED slot mid-print
  760. # makes that slot's remain% drop to 0, which the fallback below
  761. # would otherwise charge to the originally-assigned spool.
  762. def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
  763. if global_tray_id >= 254:
  764. return (255, global_tray_id - 254)
  765. if global_tray_id >= 128:
  766. return (global_tray_id, 0)
  767. return (global_tray_id // 4, global_tray_id % 4)
  768. print_used_keys: set[tuple[int, int]] = set()
  769. if ams_mapping:
  770. for gid in ams_mapping:
  771. if isinstance(gid, int) and gid >= 0:
  772. print_used_keys.add(_global_to_ams_key(gid))
  773. for change in getattr(state, "tray_change_log", None) or []:
  774. if isinstance(change, (tuple, list)) and len(change) >= 1:
  775. gid = change[0]
  776. if isinstance(gid, int) and gid >= 0:
  777. print_used_keys.add(_global_to_ams_key(gid))
  778. # 255 is not a slot: it is what ``tray_now`` reads at rest, before
  779. # the printer has reported one and while nothing is loaded, and an
  780. # unparseable reading falls back to it too. Mapped as a tray id it
  781. # becomes (255, 1), and if it were the only evidence every real
  782. # slot would be excluded and the fallback would charge nothing at
  783. # all (#1820). The external spool reports 254 when in use.
  784. if session.tray_now_at_start is not None and 0 <= session.tray_now_at_start <= 254:
  785. print_used_keys.add(_global_to_ams_key(session.tray_now_at_start))
  786. # Collect all trays to check: AMS trays + VT (external) trays
  787. # Each entry: (ams_id_for_assignment, tray_id_for_assignment, current_remain, label)
  788. trays_to_check: list[tuple[int, int, int, str]] = []
  789. for ams_unit in ams_data:
  790. ams_id = int(ams_unit.get("id", 0))
  791. for tray in ams_unit.get("tray", []):
  792. tray_id = int(tray.get("id", 0))
  793. remain = tray.get("remain", -1)
  794. trays_to_check.append((ams_id, tray_id, remain, f"AMS{ams_id}-T{tray_id}"))
  795. # VT (external) trays — same remain% delta logic
  796. vt_tray_raw = state.raw_data.get("vt_tray") or []
  797. if isinstance(vt_tray_raw, dict):
  798. vt_tray_raw = [vt_tray_raw]
  799. for vt in vt_tray_raw:
  800. if not isinstance(vt, dict):
  801. continue
  802. vt_id = int(vt.get("id", 254))
  803. vt_tray_id = vt_id - 254 # 254→0, 255→1
  804. remain = vt.get("remain", -1)
  805. trays_to_check.append((255, vt_tray_id, remain, f"VT{vt_id}"))
  806. for assign_ams_id, assign_tray_id, current_remain, tray_label in trays_to_check:
  807. key = (assign_ams_id, assign_tray_id)
  808. if key in handled_trays:
  809. continue # Already tracked via 3MF
  810. if key not in session.tray_remain_start:
  811. # No usable remain% when the print began, so there is no delta
  812. # to charge. Said out loud for the same reason as the branches
  813. # below: a slot the print used, holding a spool the operator
  814. # assigned, otherwise vanished from the accounting without a
  815. # word. Common on non-RFID spools, which report remain = -1
  816. # until a remaining amount is set by hand.
  817. if not print_used_keys or key in print_used_keys:
  818. logger.info(
  819. "[UsageTracker] %s: no valid remain%% at print start, nothing to charge for printer %d",
  820. tray_label,
  821. printer_id,
  822. )
  823. continue
  824. # Skip trays the print never touched. Only enforce when we have
  825. # evidence of which trays the print used; if print_used_keys is
  826. # empty (no mapping, no change log, no tray_now_at_start) keep
  827. # the legacy behavior of scanning every tray.
  828. if print_used_keys and key not in print_used_keys:
  829. logger.info(
  830. "[UsageTracker] %s: not in print mapping/tray_change_log — skipping fallback for printer %d",
  831. tray_label,
  832. printer_id,
  833. )
  834. continue
  835. if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
  836. logger.info(
  837. "[UsageTracker] %s: invalid remain%% at completion (%s), skipping fallback for printer %d",
  838. tray_label,
  839. current_remain,
  840. printer_id,
  841. )
  842. continue
  843. start_remain = session.tray_remain_start[key]
  844. delta_pct = start_remain - current_remain
  845. if delta_pct <= 0:
  846. # Not necessarily "nothing was printed". A fresh spool sits
  847. # at 100% for the first tens of grams, and the AMS estimate
  848. # drifts upward on its own, so a real print can end with the
  849. # same or a higher reading than it started with. Said out
  850. # loud because the alternative -- charging nothing, silently
  851. # -- is indistinguishable from having nothing to charge, and
  852. # the operator has no other way to find the prints that went
  853. # uncounted (#1820).
  854. logger.info(
  855. "[UsageTracker] %s: remain%% did not fall over the print (%d%% -> %d%%), "
  856. "nothing charged for printer %d",
  857. tray_label,
  858. start_remain,
  859. current_remain,
  860. printer_id,
  861. )
  862. continue
  863. swap_segs = spool_swaps.segments(
  864. getattr(state, "spool_change_log", None), spool_swaps.INVENTORY, assign_ams_id, assign_tray_id
  865. )
  866. if any(layer > 0 for _, layer in swap_segs[1:]):
  867. # Start and end remain% belong to two different spools: the
  868. # difference is neither one's usage (#3325). An assignment
  869. # corrected before the first layer is not a swap; the live
  870. # spool takes the delta below, as it always did.
  871. logger.warning(
  872. "[UsageTracker] %s: spool swapped during the print, remain%% delta not charged for printer %d",
  873. tray_label,
  874. printer_id,
  875. )
  876. continue
  877. spool_id = await _resolve_spool_id_for_tray(
  878. printer_id=printer_id,
  879. ams_id=assign_ams_id,
  880. tray_id=assign_tray_id,
  881. db=db,
  882. spool_assignments_snapshot=session.spool_assignments,
  883. print_started_at=session.started_at,
  884. )
  885. if spool_id is None:
  886. logger.info(
  887. "[UsageTracker] %s: no spool assigned, skipping fallback for printer %d",
  888. tray_label,
  889. printer_id,
  890. )
  891. continue
  892. # Load spool
  893. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  894. spool = spool_result.scalar_one_or_none()
  895. if not spool:
  896. continue
  897. # Compute weight consumed
  898. weight_grams = (delta_pct / 100.0) * spool.label_weight
  899. # Update spool
  900. if not await _charge_spool(db, spool, weight_grams):
  901. continue
  902. # Calculate cost for this usage
  903. cost = None
  904. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  905. if cost_per_kg > 0:
  906. cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
  907. # Insert usage history record
  908. history = SpoolUsageHistory(
  909. spool_id=spool.id,
  910. printer_id=printer_id,
  911. print_name=session.print_name,
  912. weight_used=round(weight_grams, 1),
  913. percent_used=delta_pct,
  914. status=status,
  915. cost=cost,
  916. archive_id=archive_id,
  917. )
  918. db.add(history)
  919. handled_trays.add(key)
  920. results.append(
  921. {
  922. "spool_id": spool.id,
  923. "weight_used": round(weight_grams, 1),
  924. "percent_used": delta_pct,
  925. "ams_id": assign_ams_id,
  926. "tray_id": assign_tray_id,
  927. "material": spool.material,
  928. "cost": cost,
  929. # AMS remain%-delta fallback has no 3MF slot — slot_id
  930. # stays None so it is excluded from the colour rewrite.
  931. "slot_id": None,
  932. "color": _spool_color_to_hex(spool.rgba),
  933. }
  934. )
  935. logger.info(
  936. "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d %s (AMS fallback, %s)",
  937. spool.id,
  938. weight_grams,
  939. delta_pct,
  940. printer_id,
  941. tray_label,
  942. status,
  943. )
  944. if results:
  945. await db.commit()
  946. # --- Update PrintArchive.cost from THIS print session only ---
  947. #
  948. # Cover any filament weight that wasn't tracked by an inventory spool with
  949. # the global default rate (#1344). Without this, a multi-color print where
  950. # only some AMS trays are mapped to inventory spools would record only the
  951. # mapped slots' share — e.g. $0.01 for a 110g print when 3 of 4 trays had
  952. # no spool record. The initial cost set by archive.py (total grams *
  953. # primary cost_per_kg) is fine on its own, but this block overwrites it,
  954. # so the overwrite must reconstruct the whole-print cost.
  955. if archive_id and results:
  956. from sqlalchemy import select
  957. from backend.app.models.archive import PrintArchive
  958. archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  959. archive = archive_result.scalar_one_or_none()
  960. if archive:
  961. total_cost = sum(r.get("cost", 0) or 0 for r in results)
  962. tracked_grams = sum(r.get("weight_used", 0) or 0 for r in results)
  963. archive_grams = archive.filament_used_grams or 0
  964. untracked_grams = max(0.0, archive_grams - tracked_grams)
  965. if untracked_grams > 0 and default_filament_cost > 0:
  966. total_cost += (untracked_grams / 1000.0) * default_filament_cost
  967. if total_cost > 0:
  968. # Only overwrite archive.cost on the first run. Reprint actuals
  969. # live in PrintLogEntry; the archive card keeps the first run's
  970. # cost so a failed reprint doesn't visually clobber a successful
  971. # 100 g/$X print with a 10 g/$X/10 partial (#1378).
  972. from backend.app.services.print_log import other_runs_of
  973. # Nor from a run that did not finish: the archive's cost is one
  974. # full print of the file -- the queue estimate, plate scaling and
  975. # billing read it so -- and a failed run's partial cost already
  976. # lands on its own Print Log entry. Spoolman mode never wrote it
  977. # either (COST-3).
  978. if status == "completed" and not await other_runs_of(db, archive):
  979. archive.cost = round(total_cost, 2)
  980. await db.commit()
  981. return results
  982. # A running print's ``filename`` is the path the printer is executing, and on a
  983. # sliced job that is always ``…/Metadata/plate_<N>.gcode``. Its stem names the
  984. # *plate*, not the model, and every Bambu print in existence has one — so it
  985. # identifies nothing and must never be used to match a 3MF. It reached the
  986. # matcher for real on H2-series and P2S prints, where the file goes to internal
  987. # eMMC, no 3MF can be fetched, and the archive keeps the gcode path as its
  988. # filename: `plate_1` then matched an unrelated `lid_plate_1.gcode.3mf` and that
  989. # print's filament figures were read off a different model entirely.
  990. _GENERIC_PLATE_STEM = re.compile(r"^plate_?\d+$", re.IGNORECASE)
  991. def _like_escape(value: str) -> str:
  992. """Escape LIKE metacharacters so a stem matches literally.
  993. ``_`` is a single-character wildcard, and model names are full of them.
  994. """
  995. return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
  996. def _threemf_search_stem(*candidates: str | None) -> str | None:
  997. """First candidate that names a model, or None if none of them do.
  998. Candidates are tried in order and the generic plate name is skipped rather
  999. than accepted, so a print that only has one falls through to "no match"
  1000. instead of matching everything.
  1001. """
  1002. for raw in candidates:
  1003. if not raw:
  1004. continue
  1005. stem = raw.split("/")[-1].strip()
  1006. for suffix in (".gcode.3mf", ".gcode", ".3mf"):
  1007. if stem.lower().endswith(suffix):
  1008. stem = stem[: -len(suffix)]
  1009. break
  1010. # Stripped only to judge the stem, never to change it: a real archive
  1011. # here is named "…Face Down .gcode.3mf", and a stem trimmed to
  1012. # "…Face Down" no longer matches the file it came from.
  1013. probe = stem.strip()
  1014. if probe and not _GENERIC_PLATE_STEM.match(probe):
  1015. return stem
  1016. return None
  1017. def _stem_matches(column, stem: str):
  1018. """Filter matching *stem* at a filename boundary rather than anywhere.
  1019. ``ilike("%<stem>.%")`` also matched a *suffix* of a longer name, which is how
  1020. `plate_1` reached `lid_plate_1.gcode.3mf`. A name is either the whole
  1021. basename or the basename after a directory separator.
  1022. """
  1023. escaped = _like_escape(stem)
  1024. return column.ilike(f"{escaped}.%", escape="\\") | column.ilike(f"%/{escaped}.%", escape="\\")
  1025. def _expected_plate_for_print(plate_id: int | None, gcode_file: str | None) -> int | None:
  1026. """The plate a running print is on, from whatever was recorded about it.
  1027. ``plate_id`` is the reliable source, and the archives that need a donor 3MF
  1028. have none: the no-3MF fallback row is created before any 3MF is read, so
  1029. the column is never filled. The gcode path the printer echoed is the other
  1030. source, exact on the firmwares that echo ``Metadata/plate_N.gcode``. Some
  1031. P1S builds echo only the 3MF filename, and then the plate is simply not
  1032. knowable at print start (#2957).
  1033. """
  1034. from backend.app.services.printer_manager import parse_plate_id
  1035. if plate_id is not None:
  1036. return plate_id
  1037. return parse_plate_id(gcode_file)
  1038. def _donor_3mf_conflicts(candidate, expected_plate: int | None) -> str | None:
  1039. """Why *candidate* cannot be this print's 3MF, or None if nothing rules it out.
  1040. A same-name 3MF is not the same print. Bambu Studio writes the printer-side
  1041. filename from the project's ``Title`` metadata, so every plate of a project
  1042. arrives under one name however the user renamed the file on disk, and a
  1043. donor chosen on the name alone hands one plate's slicer estimates to another
  1044. plate's print. A reporter's single-filament job was charged against three
  1045. spools that way, and nothing about the deduction said it was a guess
  1046. (#2957).
  1047. The plate is the one thing that can settle this. It is the same comparison
  1048. #1204 already makes against a freshly downloaded 3MF, so a single-plate
  1049. export is known to carry its original index rather than a renumbered 1.
  1050. Filament *count* deliberately is not checked, however tempting: the slicer's
  1051. ``ams_mapping`` is indexed by the project's filament slot -- see
  1052. ``slot_to_tray[slot_id - 1]`` below -- not by the plate's, so a real
  1053. single-filament print reports ``[0, -1, -1, -1]`` and its length says
  1054. nothing about how many filaments the plate uses.
  1055. """
  1056. from backend.app.services.archive import plate_indexes_in_3mf
  1057. if expected_plate is None:
  1058. return None
  1059. plates = plate_indexes_in_3mf(candidate)
  1060. if not plates or any(plate is None for plate in plates):
  1061. # Nothing was read, or not all of it was, and neither is evidence about
  1062. # the plate. Refusing here would drop the fallback for every 3MF variant
  1063. # this parser does not understand; downstream reports that honestly as
  1064. # "no filament usage data".
  1065. return None
  1066. if len(plates) == 1 and plates[0] != expected_plate:
  1067. return f"it holds plate {plates[0]}, this print is plate {expected_plate}"
  1068. if expected_plate not in plates:
  1069. # An all-plates export is a good donor precisely when it carries the
  1070. # plate that is running. Without this the plate is looked for
  1071. # downstream, found missing, and the whole file's filaments are summed
  1072. # onto one plate's print.
  1073. return f"it has no plate {expected_plate}"
  1074. return None
  1075. async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
  1076. """Try to find a 3MF file from library or a previous archive when the current archive has none.
  1077. This handles fallback archives (FTP download failed) where the 3MF may already exist
  1078. locally from a library upload or a previous successful print of the same file.
  1079. A name match alone does not make a candidate this print's file, so every
  1080. candidate is put through :func:`_donor_3mf_conflicts` before it is handed
  1081. back (#2957).
  1082. """
  1083. from pathlib import Path
  1084. from backend.app.models.archive import PrintArchive
  1085. from backend.app.models.library import LibraryFile
  1086. # Derive search name from archive filename (e.g. "benchy.3mf" or "benchy.gcode.3mf"),
  1087. # falling back to the print name when the filename is only a plate path.
  1088. search_base = _threemf_search_stem(archive.filename, archive.print_name)
  1089. if not search_base:
  1090. return None
  1091. print_data = (getattr(archive, "extra_data", None) or {}).get("_print_data") or {}
  1092. expected_plate = _expected_plate_for_print(
  1093. getattr(archive, "plate_id", None),
  1094. archive.filename or print_data.get("filename"),
  1095. )
  1096. if expected_plate is None:
  1097. # Worth saying out loud. On the firmwares that echo only the 3MF
  1098. # filename there is nothing to check a donor against, so whatever is
  1099. # accepted below is accepted on its name alone -- which is how the
  1100. # reporter's spools were debited for another plate's filament. The
  1101. # deduction being silent was half the bug (#2957).
  1102. logger.warning(
  1103. "[UsageTracker] 3MF fallback: archive %s does not know its plate (%r), so a same-named "
  1104. "3MF can only be matched on its name",
  1105. archive.id,
  1106. archive.filename,
  1107. )
  1108. # 1. Try library files matching the name (match base name at file boundary)
  1109. try:
  1110. lib_result = await db.execute(
  1111. LibraryFile.active()
  1112. .where(_stem_matches(LibraryFile.file_path, search_base))
  1113. .where(LibraryFile.file_path.ilike("%.3mf"))
  1114. .order_by(LibraryFile.created_at.desc())
  1115. .limit(3)
  1116. )
  1117. for lib_file in lib_result.scalars().all():
  1118. lib_path = Path(lib_file.file_path)
  1119. candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
  1120. if candidate.exists() and candidate.suffix == ".3mf":
  1121. conflict = _donor_3mf_conflicts(candidate, expected_plate)
  1122. if conflict:
  1123. logger.warning(
  1124. "[UsageTracker] 3MF fallback: not using library file %s for archive %s — %s",
  1125. candidate,
  1126. archive.id,
  1127. conflict,
  1128. )
  1129. continue
  1130. logger.info(
  1131. "[UsageTracker] 3MF fallback: found library file %s for archive %s (expected plate=%s)",
  1132. candidate,
  1133. archive.id,
  1134. expected_plate,
  1135. )
  1136. return candidate
  1137. except Exception as e:
  1138. logger.debug("[UsageTracker] 3MF fallback: library lookup failed: %s", e)
  1139. # 2. Try previous archives with the same filename that have a valid file_path
  1140. try:
  1141. prev_result = await db.execute(
  1142. select(PrintArchive)
  1143. .where(PrintArchive.id != archive.id)
  1144. .where(PrintArchive.printer_id == archive.printer_id)
  1145. .where(PrintArchive.file_path != "")
  1146. .where(PrintArchive.file_path.isnot(None))
  1147. .where(_stem_matches(PrintArchive.filename, search_base))
  1148. .order_by(PrintArchive.created_at.desc())
  1149. .limit(3)
  1150. )
  1151. for prev_archive in prev_result.scalars().all():
  1152. candidate = base_dir / prev_archive.file_path
  1153. if candidate.exists() and candidate.suffix == ".3mf":
  1154. conflict = _donor_3mf_conflicts(candidate, expected_plate)
  1155. if conflict:
  1156. logger.warning(
  1157. "[UsageTracker] 3MF fallback: not using archive %s's file for archive %s — %s",
  1158. prev_archive.id,
  1159. archive.id,
  1160. conflict,
  1161. )
  1162. continue
  1163. logger.info(
  1164. "[UsageTracker] 3MF fallback: found previous archive %s file for archive %s (expected plate=%s)",
  1165. prev_archive.id,
  1166. archive.id,
  1167. expected_plate,
  1168. )
  1169. return candidate
  1170. except Exception as e:
  1171. logger.debug("[UsageTracker] 3MF fallback: previous archive lookup failed: %s", e)
  1172. return None
  1173. async def _find_3mf_by_filename(
  1174. printer_id: int,
  1175. filename: str,
  1176. db: AsyncSession,
  1177. base_dir,
  1178. print_name: str | None = None,
  1179. ):
  1180. """Find a 3MF file by filename from library or previous archives.
  1181. Used when auto-archive is disabled and there's no archive_id, but we still
  1182. need the 3MF slicer data for filament usage tracking.
  1183. ``print_name`` is the model name to fall back to when ``filename`` is the
  1184. printer's plate path, which names no model at all -- and when it is that
  1185. plate path, it is also what keeps a same-named file for a different plate
  1186. from being adopted (#2957); see :func:`_donor_3mf_conflicts`.
  1187. """
  1188. from pathlib import Path
  1189. from backend.app.models.archive import PrintArchive
  1190. from backend.app.models.library import LibraryFile
  1191. search_base = _threemf_search_stem(filename, print_name)
  1192. if not search_base:
  1193. return None
  1194. expected_plate = _expected_plate_for_print(None, filename)
  1195. # 1. Try library files matching the name
  1196. try:
  1197. lib_result = await db.execute(
  1198. LibraryFile.active()
  1199. .where(_stem_matches(LibraryFile.file_path, search_base))
  1200. .where(LibraryFile.file_path.ilike("%.3mf"))
  1201. .order_by(LibraryFile.created_at.desc())
  1202. .limit(3)
  1203. )
  1204. for lib_file in lib_result.scalars().all():
  1205. lib_path = Path(lib_file.file_path)
  1206. candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
  1207. if candidate.exists() and candidate.suffix == ".3mf":
  1208. conflict = _donor_3mf_conflicts(candidate, expected_plate)
  1209. if conflict:
  1210. logger.warning(
  1211. "[UsageTracker] 3MF (no-archive): not using library file %s for '%s' — %s",
  1212. candidate,
  1213. filename,
  1214. conflict,
  1215. )
  1216. continue
  1217. logger.info("[UsageTracker] 3MF (no-archive): found library file %s for '%s'", candidate, filename)
  1218. return candidate
  1219. except Exception as e:
  1220. logger.debug("[UsageTracker] 3MF (no-archive): library lookup failed: %s", e)
  1221. # 2. Try previous archives with a valid 3MF file_path
  1222. try:
  1223. prev_result = await db.execute(
  1224. select(PrintArchive)
  1225. .where(PrintArchive.printer_id == printer_id)
  1226. .where(PrintArchive.file_path != "")
  1227. .where(PrintArchive.file_path.isnot(None))
  1228. .where(_stem_matches(PrintArchive.filename, search_base))
  1229. .order_by(PrintArchive.created_at.desc())
  1230. .limit(3)
  1231. )
  1232. for prev_archive in prev_result.scalars().all():
  1233. candidate = base_dir / prev_archive.file_path
  1234. if candidate.exists() and candidate.suffix == ".3mf":
  1235. conflict = _donor_3mf_conflicts(candidate, expected_plate)
  1236. if conflict:
  1237. logger.warning(
  1238. "[UsageTracker] 3MF (no-archive): not using archive %s's file for '%s' — %s",
  1239. prev_archive.id,
  1240. filename,
  1241. conflict,
  1242. )
  1243. continue
  1244. logger.info(
  1245. "[UsageTracker] 3MF (no-archive): found previous archive %s file for '%s'",
  1246. prev_archive.id,
  1247. filename,
  1248. )
  1249. return candidate
  1250. except Exception as e:
  1251. logger.debug("[UsageTracker] 3MF (no-archive): previous archive lookup failed: %s", e)
  1252. return None
  1253. async def _track_from_3mf(
  1254. printer_id: int,
  1255. archive_id: int | None,
  1256. status: str,
  1257. print_name: str,
  1258. handled_trays: set[tuple[int, int]],
  1259. printer_manager,
  1260. db: AsyncSession,
  1261. ams_mapping: list[int] | None = None,
  1262. tray_now_at_start: int = -1,
  1263. last_progress: float = 0.0,
  1264. last_layer_num: int = 0,
  1265. default_filament_cost: float = 0.0,
  1266. spool_assignments: dict[tuple[int, int], int] | None = None,
  1267. print_started_at: datetime | None = None,
  1268. threemf_path=None,
  1269. plate_id: int | None = None,
  1270. ) -> list[dict]:
  1271. """Track usage from 3MF per-filament slicer data (primary path).
  1272. Uses slicer-estimated filament weight for all spools (BL and non-BL).
  1273. For partial prints (failed/aborted), tries per-layer gcode data first,
  1274. then falls back to linear scaling by progress.
  1275. When archive_id is None (auto-archive disabled), a pre-resolved threemf_path
  1276. can be provided to still track filament usage from slicer data.
  1277. When ``plate_id`` is set (queue prints of a single plate from a multi-plate
  1278. 3MF), only that plate's filaments contribute. Without it the 3MF parser sums
  1279. every plate, which is correct for direct/library Print flows that always
  1280. target the first or only plate (#1697).
  1281. Slot-to-tray mapping priority:
  1282. 1. Stored ams_mapping from print command (reprints/direct prints)
  1283. 2. MQTT mapping field from printer state (universal, all print sources)
  1284. 3. Queue item ams_mapping (for queue-initiated prints)
  1285. 4. tray_now from printer state (for single-filament non-queue prints)
  1286. 5. Position-based default using sorted available tray IDs (handles external spools)
  1287. 6. Default mapping: slot_id - 1 = global_tray_id (last resort)
  1288. """
  1289. from pathlib import Path
  1290. from backend.app.core.config import settings as app_settings
  1291. from backend.app.models.archive import PrintArchive
  1292. from backend.app.models.print_queue import PrintQueueItem
  1293. from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
  1294. file_path: Path | None = threemf_path
  1295. archive: PrintArchive | None = None
  1296. if file_path is None and archive_id:
  1297. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  1298. archive = result.scalar_one_or_none()
  1299. if not archive:
  1300. logger.info("[UsageTracker] 3MF: archive %s not found, skipping", archive_id)
  1301. return []
  1302. # Try archive's own file_path first
  1303. if archive.file_path:
  1304. candidate = app_settings.base_dir / archive.file_path
  1305. if candidate.exists():
  1306. file_path = candidate
  1307. # Fallback: find 3MF from library or a previous archive with the same filename
  1308. if file_path is None:
  1309. file_path = await _resolve_3mf_fallback(archive, db, app_settings.base_dir)
  1310. if file_path is None:
  1311. logger.info("[UsageTracker] 3MF: no file available for archive %s, skipping", archive_id)
  1312. return []
  1313. # The queue item carries both the plate and the dispatched mapping; look it
  1314. # up at most once. A batch dispatches one archive as several queue items,
  1315. # on several printers at once: only this printer's newest is this run.
  1316. # Completion has already closed it, as cancelled for an aborted print.
  1317. _queue_item_lookup: list = []
  1318. async def _dispatch_queue_item():
  1319. if not _queue_item_lookup:
  1320. if not archive_id:
  1321. _queue_item_lookup.append(None)
  1322. else:
  1323. queue_result = await db.execute(
  1324. select(PrintQueueItem)
  1325. .where(PrintQueueItem.archive_id == archive_id)
  1326. .where(PrintQueueItem.printer_id == printer_id)
  1327. .where(PrintQueueItem.status.in_(["printing", "completed", "failed", "cancelled"]))
  1328. .order_by(PrintQueueItem.id.desc())
  1329. )
  1330. _queue_item_lookup.append(queue_result.scalars().first())
  1331. return _queue_item_lookup[0]
  1332. # The caller's plate_id comes from the in-memory session, which a restart
  1333. # mid-print destroys. Both the archive and the queue item recorded the
  1334. # plate at dispatch — without falling back to them the parser sums every
  1335. # plate of a multi-plate file and charges the lot to one spool. The
  1336. # newest queue item goes first: the archive keeps the plate of its first
  1337. # print, so a reprint of another plate is only on the queue item.
  1338. if plate_id is None:
  1339. plate_queue_item = await _dispatch_queue_item()
  1340. if plate_queue_item is not None and plate_queue_item.plate_id is not None:
  1341. plate_id = plate_queue_item.plate_id
  1342. logger.info(
  1343. "[UsageTracker] 3MF: plate_id=%s recovered from queue item %s",
  1344. plate_id,
  1345. plate_queue_item.id,
  1346. )
  1347. elif archive is not None and archive.plate_id is not None:
  1348. plate_id = archive.plate_id
  1349. logger.info("[UsageTracker] 3MF: plate_id=%s recovered from archive %s", plate_id, archive_id)
  1350. filament_usage = extract_filament_usage_from_3mf(file_path, plate_id)
  1351. if not filament_usage and plate_id is not None:
  1352. # The plate isn't in this file. That happens when the archive's own 3MF
  1353. # is gone and `_resolve_3mf_fallback` substituted a same-named file from
  1354. # the library that was sliced with different plates. Summing the whole
  1355. # file is wrong for a single-plate run, but it is closer than recording
  1356. # nothing at all — and unlike the silent whole-file sum this replaces,
  1357. # it says so.
  1358. filament_usage = extract_filament_usage_from_3mf(file_path, None)
  1359. if filament_usage:
  1360. logger.warning(
  1361. "[UsageTracker] 3MF: plate %s not present in %s — falling back to the whole-file total",
  1362. plate_id,
  1363. file_path,
  1364. )
  1365. plate_id = None
  1366. if not filament_usage:
  1367. logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
  1368. return []
  1369. logger.info("[UsageTracker] 3MF: archive %s, plate_id=%s, filament_usage=%s", archive_id, plate_id, filament_usage)
  1370. # --- Resolve slot-to-tray mapping ---
  1371. mapping_source = None
  1372. # 1. Use stored ams_mapping from the print command (reprints/direct prints)
  1373. slot_to_tray = ams_mapping
  1374. if slot_to_tray:
  1375. mapping_source = "print_cmd"
  1376. # 2. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
  1377. #
  1378. # Ranked above the live MQTT field on purpose: `mapping` reports the tray
  1379. # the printer is feeding from *now*, and AMS filament backup rewrites it to
  1380. # the substitute tray when a spool runs dry. Read at completion it names
  1381. # the tray that finished the print, not the one the slicer assigned — the
  1382. # queue item's copy is the mapping the print was actually dispatched with.
  1383. if not slot_to_tray and archive_id:
  1384. queue_item = await _dispatch_queue_item()
  1385. if queue_item and queue_item.ams_mapping:
  1386. try:
  1387. slot_to_tray = json.loads(queue_item.ams_mapping)
  1388. mapping_source = "queue"
  1389. except (json.JSONDecodeError, TypeError):
  1390. pass
  1391. # 3. Try MQTT mapping field from printer state (universal, all print sources)
  1392. if not slot_to_tray:
  1393. state = printer_manager.get_status(printer_id)
  1394. raw_data = getattr(state, "raw_data", None) if state else None
  1395. if raw_data:
  1396. mqtt_mapping = raw_data.get("mapping")
  1397. decoded = _decode_mqtt_mapping(mqtt_mapping)
  1398. if decoded:
  1399. slot_to_tray = decoded
  1400. mapping_source = "mqtt"
  1401. # 4. Color-match 3MF filament slots to AMS trays (for printers without mapping field)
  1402. if not slot_to_tray:
  1403. state = printer_manager.get_status(printer_id)
  1404. raw_data = getattr(state, "raw_data", None) if state else None
  1405. if raw_data:
  1406. matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
  1407. if matched:
  1408. slot_to_tray = matched
  1409. mapping_source = "color_match"
  1410. logger.info(
  1411. "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
  1412. slot_to_tray,
  1413. mapping_source or "none",
  1414. )
  1415. # A mapping that names no tray for any slot the print used carries no
  1416. # evidence at all; see mapping_names_no_used_tray. Drop it so the tray_now
  1417. # evidence below decides, rather than leaving every slot unfed.
  1418. if mapping_names_no_used_tray(slot_to_tray, filament_usage):
  1419. logger.info(
  1420. "[UsageTracker] 3MF: mapping %s names no tray for the used slots — ignoring it",
  1421. slot_to_tray,
  1422. )
  1423. slot_to_tray = None
  1424. mapping_source = None
  1425. # 5. For single-filament non-queue prints, use tray_now from printer state
  1426. # Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now
  1427. # > last_loaded_tray > vt_tray check
  1428. #
  1429. # tray_change_log evidence wins over slot_to_tray when present: if the
  1430. # printer fed from multiple trays mid-print (AMS auto-fallback when one
  1431. # spool runs out, #957), the slicer's mapping captured at print start
  1432. # is stale and needs to be replaced with per-layer split attribution.
  1433. nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
  1434. tray_now_override: int | None = None
  1435. tray_changes: list[tuple[int, int]] = [] # [(global_tray_id, layer_num), ...]
  1436. state = printer_manager.get_status(printer_id) if len(nonzero_slots) == 1 else None
  1437. if state is not None:
  1438. tray_changes = getattr(state, "tray_change_log", []) or []
  1439. elif len(nonzero_slots) > 1:
  1440. # Multi-material print: every filament change moves tray_now, so the
  1441. # log can't be read as "this slot moved to that tray" and splitting
  1442. # would attribute worse than the mapping does. Say so rather than
  1443. # silently dropping the evidence — a runout mid-print on a
  1444. # multi-material job still lands entirely on the mapped tray.
  1445. _multi_state = printer_manager.get_status(printer_id)
  1446. if len(getattr(_multi_state, "tray_change_log", []) or []) > 1:
  1447. logger.warning(
  1448. "[UsageTracker] 3MF: %d tray changes observed but %d filament slots used — "
  1449. "splitting needs a single slot, attributing by mapping alone (printer %d, archive %s)",
  1450. len(_multi_state.tray_change_log),
  1451. len(nonzero_slots),
  1452. printer_id,
  1453. archive_id,
  1454. )
  1455. if len(tray_changes) > 1:
  1456. # Multi-tray usage detected — splitting takes over regardless of slot_to_tray.
  1457. logger.info("[UsageTracker] 3MF: tray change log: %s (will split weight)", tray_changes)
  1458. elif not slot_to_tray and len(nonzero_slots) == 1:
  1459. if 0 <= tray_now_at_start <= 254:
  1460. tray_now_override = tray_now_at_start
  1461. logger.info("[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)", tray_now_at_start)
  1462. elif state and 0 <= state.tray_now <= 254:
  1463. tray_now_override = state.tray_now
  1464. logger.info("[UsageTracker] 3MF: using current tray_now=%d", state.tray_now)
  1465. elif (
  1466. state
  1467. and state.tray_now == 255
  1468. and has_second_external_holder(getattr(state, "raw_data", None))
  1469. and not any(isinstance(c, (tuple, list)) and c and 0 <= c[0] <= 254 for c in tray_changes)
  1470. ):
  1471. # 255 = "no filament" on legacy printers, but valid 2nd external
  1472. # spool on H2-series. Ahead of last_loaded_tray: a print from that
  1473. # holder never updates it, so it still names the tray an earlier
  1474. # print used (#3405). A tray the printer switched to during this
  1475. # print says the 255 is an unload after it, not the feed.
  1476. tray_now_override = state.tray_now
  1477. logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
  1478. elif state and 0 <= state.last_loaded_tray <= 253:
  1479. tray_now_override = state.last_loaded_tray
  1480. logger.info("[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)", state.last_loaded_tray)
  1481. if tray_now_override is None:
  1482. logger.info(
  1483. "[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",
  1484. tray_now_at_start,
  1485. state.tray_now if state else "N/A",
  1486. state.last_loaded_tray if state else "N/A",
  1487. )
  1488. # Scale factor for partial prints (failed/aborted)
  1489. if status == "completed":
  1490. scale = 1.0
  1491. else:
  1492. state = printer_manager.get_status(printer_id)
  1493. progress = state.progress if state else 0
  1494. # Firmware resets progress to 0 on cancel — use last valid progress captured during print
  1495. if progress <= 0 and last_progress > 0:
  1496. progress = last_progress
  1497. logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
  1498. scale = max(0.0, min(progress / 100.0, 1.0))
  1499. # Per-layer gcode accuracy for partial prints
  1500. layer_grams: dict[int, float] | None = None
  1501. # The same in the G-code's own grams, which the tray split weighs segments in.
  1502. layer_gcode_grams: dict[int, float] = {}
  1503. if status != "completed":
  1504. state = printer_manager.get_status(printer_id)
  1505. current_layer = state.layer_num if state else 0
  1506. # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
  1507. if current_layer <= 0 and last_layer_num > 0:
  1508. current_layer = last_layer_num
  1509. logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
  1510. if current_layer > 0:
  1511. try:
  1512. from backend.app.utils.threemf_tools import (
  1513. extract_filament_properties_from_3mf,
  1514. extract_layer_filament_usage_from_3mf,
  1515. get_cumulative_usage_at_layer,
  1516. mm_to_grams,
  1517. slicer_grams_at_layer,
  1518. )
  1519. layer_usage = extract_layer_filament_usage_from_3mf(file_path, plate_id)
  1520. if layer_usage:
  1521. cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
  1522. filament_props = extract_filament_properties_from_3mf(file_path)
  1523. # On the slicer's weights, as a finished print is (#3317).
  1524. layer_grams = slicer_grams_at_layer(layer_usage, current_layer, filament_usage)
  1525. for filament_id, mm_used in cumulative_mm.items():
  1526. slot_id = filament_id + 1 # 0-based to 1-based
  1527. props = filament_props.get(slot_id, {})
  1528. density = props.get("density", 1.24)
  1529. diameter = props.get("diameter", 1.75)
  1530. layer_gcode_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
  1531. layer_grams.setdefault(slot_id, layer_gcode_grams[slot_id])
  1532. except Exception:
  1533. pass # Fall back to linear scaling
  1534. results = []
  1535. # Trays this print drew from that no longer have an assignment to charge.
  1536. # Collected rather than acted on inline so one notification covers the whole
  1537. # print instead of one per slot (#2812).
  1538. unassigned_global_trays: list[int] = []
  1539. # Deduplicated by slicer slot, not by tray (#3230). Several slots mapped to
  1540. # one tray, or a single-slot print that returns to an earlier tray, each
  1541. # consume filament of their own. A slot only repeats when the plate is
  1542. # unknown and every plate of the file is listed, and that must not charge
  1543. # the same slot once per plate. Only a tray the caller had already covered
  1544. # before this pass is skipped.
  1545. charged_slots: set[int] = set()
  1546. covered_before = frozenset(handled_trays)
  1547. # Spools swapped by hand on one tray mid-print (#3325). The G-code's
  1548. # per-layer usage is read only when a tray was actually swapped.
  1549. _swap_state = printer_manager.get_status(printer_id)
  1550. _swap_total = getattr(_swap_state, "total_layers", 0)
  1551. _swap_layer = getattr(_swap_state, "layer_num", 0)
  1552. if status == "completed":
  1553. _swap_end = max(_swap_total if isinstance(_swap_total, int) else 0, last_layer_num)
  1554. else:
  1555. _swap_end = _swap_layer if isinstance(_swap_layer, int) and _swap_layer > 0 else last_layer_num
  1556. def _read_layer_usage():
  1557. from backend.app.utils.threemf_tools import extract_layer_filament_usage_from_3mf
  1558. return extract_layer_filament_usage_from_3mf(file_path, plate_id)
  1559. _swap_shares = spool_swaps.Splitter(
  1560. getattr(_swap_state, "spool_change_log", None) if _swap_state else None,
  1561. spool_swaps.INVENTORY,
  1562. _read_layer_usage,
  1563. _swap_end,
  1564. completed=status == "completed",
  1565. ).shares
  1566. for usage in filament_usage:
  1567. slot_id = usage.get("slot_id", 0)
  1568. used_g = usage.get("used_g", 0)
  1569. if used_g <= 0:
  1570. continue
  1571. if slot_id in charged_slots:
  1572. continue
  1573. charged_slots.add(slot_id)
  1574. # --- Mid-print tray switch: split weight across trays ---
  1575. # Split math is shared with the Spoolman writer via
  1576. # ``utils.tray_split.compute_tray_split_grams`` (#1793) — both
  1577. # inventory backends must attribute segments identically or a
  1578. # user running dual-mode sees divergent totals.
  1579. if len(tray_changes) > 1:
  1580. # Compute total weight for this slot (same logic as normal path)
  1581. if layer_grams and slot_id in layer_grams:
  1582. total_weight = layer_grams[slot_id]
  1583. else:
  1584. total_weight = used_g * scale
  1585. if total_weight <= 0:
  1586. continue
  1587. # Extract per-layer gcode for segment splitting
  1588. split_layer_usage = None
  1589. split_props: dict = {}
  1590. try:
  1591. from backend.app.utils.threemf_tools import (
  1592. extract_filament_properties_from_3mf,
  1593. extract_layer_filament_usage_from_3mf,
  1594. )
  1595. split_layer_usage = extract_layer_filament_usage_from_3mf(file_path, plate_id)
  1596. filament_props = extract_filament_properties_from_3mf(file_path)
  1597. split_props = filament_props.get(slot_id, {})
  1598. except Exception:
  1599. pass # Fall back to linear splitting
  1600. from backend.app.utils.tray_split import compute_tray_split_grams
  1601. # The split prices segments in the G-code's grams and gives the last
  1602. # the rest, so it is run on the G-code's total and scaled to the
  1603. # slicer's after (#3317); a slicer total would leave the backup
  1604. # spool short, or with nothing.
  1605. split_total = layer_gcode_grams.get(slot_id) or total_weight
  1606. segments = compute_tray_split_grams(
  1607. tray_changes=tray_changes,
  1608. total_weight=split_total,
  1609. slot_id=slot_id,
  1610. layer_usage=split_layer_usage,
  1611. density=split_props.get("density", 1.24),
  1612. diameter=split_props.get("diameter", 1.75),
  1613. total_layers=(state.total_layers if state else 0) or 0,
  1614. last_layer_num=last_layer_num,
  1615. )
  1616. segments = [(i, tray, grams * total_weight / split_total) for i, tray, grams in segments]
  1617. for seg_idx, tray_global, segment_grams in segments:
  1618. if segment_grams <= 0:
  1619. continue
  1620. # Convert global tray ID to (ams_id, tray_id)
  1621. if tray_global >= 254:
  1622. seg_ams_id = 255
  1623. seg_tray_id = tray_global - 254
  1624. elif tray_global >= 128:
  1625. seg_ams_id = tray_global
  1626. seg_tray_id = 0
  1627. else:
  1628. seg_ams_id = tray_global // 4
  1629. seg_tray_id = tray_global % 4
  1630. seg_key = (seg_ams_id, seg_tray_id)
  1631. if seg_key in covered_before:
  1632. continue
  1633. seg_start_layer = tray_changes[seg_idx][1]
  1634. is_last = seg_idx + 1 >= len(tray_changes)
  1635. logger.info(
  1636. "[UsageTracker] 3MF split: segment %d tray=%d (AMS%d-T%d) layers %d-%s -> %.1fg",
  1637. seg_idx,
  1638. tray_global,
  1639. seg_ams_id,
  1640. seg_tray_id,
  1641. seg_start_layer,
  1642. tray_changes[seg_idx + 1][1] if not is_last else "end",
  1643. segment_grams,
  1644. )
  1645. # A spool swapped by hand on this tray within the segment (#3325).
  1646. seg_shares = _swap_shares(
  1647. seg_ams_id,
  1648. seg_tray_id,
  1649. slot_id,
  1650. segment_grams,
  1651. seg_start_layer,
  1652. None if is_last else tray_changes[seg_idx + 1][1],
  1653. )
  1654. seg_spool_id = await _resolve_spool_id_for_tray(
  1655. printer_id=printer_id,
  1656. ams_id=seg_ams_id,
  1657. tray_id=seg_tray_id,
  1658. db=db,
  1659. spool_assignments_snapshot=spool_assignments,
  1660. print_started_at=print_started_at,
  1661. )
  1662. if seg_shares is None and seg_spool_id is None:
  1663. logger.info(
  1664. "[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
  1665. printer_id,
  1666. seg_ams_id,
  1667. seg_tray_id,
  1668. )
  1669. continue
  1670. for share_spool_id, share_grams in seg_shares or [(seg_spool_id, segment_grams)]:
  1671. spool_result = await db.execute(select(Spool).where(Spool.id == share_spool_id))
  1672. spool = spool_result.scalar_one_or_none()
  1673. if not spool:
  1674. continue
  1675. if not await _charge_spool(db, spool, share_grams):
  1676. continue
  1677. percent = round(share_grams / (spool.label_weight or 1000) * 100)
  1678. cost = None
  1679. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  1680. if cost_per_kg > 0:
  1681. cost = round((share_grams / 1000.0) * cost_per_kg, 2)
  1682. history = SpoolUsageHistory(
  1683. spool_id=spool.id,
  1684. printer_id=printer_id,
  1685. print_name=print_name,
  1686. weight_used=round(share_grams, 1),
  1687. percent_used=percent,
  1688. status=status,
  1689. cost=cost,
  1690. archive_id=archive_id,
  1691. )
  1692. db.add(history)
  1693. handled_trays.add(seg_key)
  1694. results.append(
  1695. {
  1696. "spool_id": spool.id,
  1697. "weight_used": round(share_grams, 1),
  1698. "percent_used": percent,
  1699. "ams_id": seg_ams_id,
  1700. "tray_id": seg_tray_id,
  1701. "material": spool.material,
  1702. "cost": cost,
  1703. "slot_id": slot_id,
  1704. "color": _spool_color_to_hex(spool.rgba),
  1705. }
  1706. )
  1707. logger.info(
  1708. "[UsageTracker] Spool %d consumed %.1fg (3MF split seg%d) on printer %d AMS%d-T%d (%s)",
  1709. spool.id,
  1710. share_grams,
  1711. seg_idx,
  1712. printer_id,
  1713. seg_ams_id,
  1714. seg_tray_id,
  1715. status,
  1716. )
  1717. continue # Skip normal single-tray processing for this slot
  1718. # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping
  1719. tray_is_guessed = False
  1720. if tray_now_override is not None:
  1721. # Single-filament non-queue print: use actual tray from printer state
  1722. global_tray_id = tray_now_override
  1723. else:
  1724. # Explicit mapping (print command, MQTT, queue, color match)
  1725. global_tray_id = None
  1726. if slot_to_tray and slot_id <= len(slot_to_tray):
  1727. mapped = slot_to_tray[slot_id - 1]
  1728. if isinstance(mapped, int) and mapped >= 0:
  1729. global_tray_id = mapped
  1730. elif mapped == -1:
  1731. # The mapping says this slot isn't fed from any tray. The
  1732. # position-based guess below would hand it the Nth loaded
  1733. # tray anyway -- an AMS spool that never moved (#3166), or
  1734. # the tray another slot really used, which that slot then
  1735. # finds already handled and drops (#2880).
  1736. logger.info(
  1737. "[UsageTracker] 3MF: slot_id=%d is unmapped (-1) — nothing charged (used_g=%.1f)",
  1738. slot_id,
  1739. used_g,
  1740. )
  1741. continue
  1742. # Position-based default: sort available tray IDs so external spools (254/255)
  1743. # naturally follow standard AMS trays, matching slicer slot numbering.
  1744. #
  1745. # Filter out AMS slots that have no spool loaded (empty `tray_type`) —
  1746. # BambuStudio/OrcaSlicer compact the slot list when assigning filaments
  1747. # and don't expose empty AMS slots to the user, so the slicer's 3MF
  1748. # slot N maps to the Nth *loaded* tray, not the Nth physical position.
  1749. # Without this filter a "3 AMS slots loaded + 1 empty + external"
  1750. # layout routes the slicer's 4th filament to the empty AMS slot
  1751. # instead of the external (#1607), and the external's spool usage
  1752. # never gets recorded. vt_tray entries are already filtered the
  1753. # same way inside `build_ams_tray_lookup` (line 174 checks
  1754. # `tray_type`), so this just mirrors that for the AMS side.
  1755. tray_is_guessed = global_tray_id is None
  1756. if global_tray_id is None:
  1757. _state = printer_manager.get_status(printer_id)
  1758. _raw = getattr(_state, "raw_data", None) if _state else None
  1759. if _raw:
  1760. from backend.app.services.spoolman_tracking import build_ams_tray_lookup
  1761. _lookup = build_ams_tray_lookup(_raw)
  1762. available_trays = sorted(gid for gid, info in _lookup.items() if info.get("tray_type"))
  1763. if slot_id <= len(available_trays):
  1764. global_tray_id = available_trays[slot_id - 1]
  1765. # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)
  1766. if global_tray_id is None:
  1767. global_tray_id = slot_id - 1
  1768. if global_tray_id >= 254:
  1769. # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
  1770. ams_id = 255
  1771. tray_id = global_tray_id - 254
  1772. elif global_tray_id >= 128:
  1773. ams_id = global_tray_id
  1774. tray_id = 0
  1775. else:
  1776. ams_id = global_tray_id // 4
  1777. tray_id = global_tray_id % 4
  1778. logger.info(
  1779. "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",
  1780. slot_id,
  1781. global_tray_id,
  1782. ams_id,
  1783. tray_id,
  1784. used_g,
  1785. tray_now_override,
  1786. )
  1787. key = (ams_id, tray_id)
  1788. if key in covered_before:
  1789. continue
  1790. if tray_is_guessed and key in handled_trays:
  1791. # A guess by position can land a second slot on a tray another slot
  1792. # was charged for. That says nothing about which spool fed it, so
  1793. # it is not charged to that tray's spool as well.
  1794. continue
  1795. spool_id = await _resolve_spool_id_for_tray(
  1796. printer_id=printer_id,
  1797. ams_id=ams_id,
  1798. tray_id=tray_id,
  1799. db=db,
  1800. spool_assignments_snapshot=spool_assignments,
  1801. print_started_at=print_started_at,
  1802. )
  1803. # Use per-layer grams if available, otherwise linear scale
  1804. if layer_grams and slot_id in layer_grams:
  1805. slot_grams = layer_grams[slot_id]
  1806. else:
  1807. slot_grams = used_g * scale
  1808. # A spool swapped by hand on this tray mid-print shares it (#3325).
  1809. shares = _swap_shares(ams_id, tray_id, slot_id, slot_grams) if slot_grams > 0 else None
  1810. if shares is None and spool_id is None:
  1811. # WARNING, not INFO: everything upstream of this line succeeded --
  1812. # the 3MF was found, the grams were read, the tray resolved -- and
  1813. # the print will still report success while this filament is never
  1814. # deducted. At INFO it was invisible under the default log level and
  1815. # absent from the reasoning in support bundles (#2812).
  1816. logger.warning(
  1817. "[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d — %.1fg not deducted",
  1818. printer_id,
  1819. ams_id,
  1820. tray_id,
  1821. used_g,
  1822. )
  1823. unassigned_global_trays.append(global_tray_id)
  1824. continue
  1825. if slot_grams <= 0:
  1826. continue
  1827. for share_spool_id, weight_grams in shares or [(spool_id, slot_grams)]:
  1828. # Load spool
  1829. spool_result = await db.execute(select(Spool).where(Spool.id == share_spool_id))
  1830. spool = spool_result.scalar_one_or_none()
  1831. if not spool:
  1832. continue
  1833. # Update spool
  1834. if not await _charge_spool(db, spool, weight_grams):
  1835. continue
  1836. percent = round(weight_grams / (spool.label_weight or 1000) * 100)
  1837. # Calculate cost for this usage
  1838. cost = None
  1839. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  1840. if cost_per_kg > 0:
  1841. cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
  1842. # Insert usage history record
  1843. history = SpoolUsageHistory(
  1844. spool_id=spool.id,
  1845. printer_id=printer_id,
  1846. print_name=print_name,
  1847. weight_used=round(weight_grams, 1),
  1848. percent_used=percent,
  1849. status=status,
  1850. cost=cost,
  1851. archive_id=archive_id,
  1852. )
  1853. db.add(history)
  1854. handled_trays.add(key)
  1855. results.append(
  1856. {
  1857. "spool_id": spool.id,
  1858. "weight_used": round(weight_grams, 1),
  1859. "percent_used": percent,
  1860. "ams_id": ams_id,
  1861. "tray_id": tray_id,
  1862. "material": spool.material,
  1863. "cost": cost,
  1864. "slot_id": slot_id,
  1865. "color": _spool_color_to_hex(spool.rgba),
  1866. }
  1867. )
  1868. # Determine mapping source for debug logging
  1869. if tray_now_override is not None:
  1870. map_src = ", tray_now"
  1871. elif mapping_source:
  1872. map_src = f", {mapping_source}_map"
  1873. else:
  1874. map_src = ""
  1875. logger.info(
  1876. "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)",
  1877. spool.id,
  1878. weight_grams,
  1879. " per-layer"
  1880. if (layer_grams and slot_id in layer_grams)
  1881. else (f" scaled {scale:.0%}" if scale < 1 else ""),
  1882. map_src,
  1883. printer_id,
  1884. ams_id,
  1885. tray_id,
  1886. status,
  1887. )
  1888. # --- Adopt the matched inventory spools' colours for the archive (#1494) ---
  1889. # The archive's filament_color was set from the slicer's 3MF at creation
  1890. # time; now that every used slot has been resolved to an inventory spool,
  1891. # the curated spool colour is authoritative. Committed by the caller's
  1892. # `if results: await db.commit()`.
  1893. if archive is not None:
  1894. spool_colors = _archive_colors_from_spools(filament_usage, results)
  1895. if spool_colors:
  1896. joined = ",".join(spool_colors)
  1897. if joined != archive.filament_color:
  1898. logger.info(
  1899. "[UsageTracker] 3MF: archive %s filament_color %r -> %r (from inventory spools)",
  1900. archive_id,
  1901. archive.filament_color,
  1902. joined,
  1903. )
  1904. archive.filament_color = joined
  1905. # Adopt the matched spools' materials too (#2563) — a slot mapped to a
  1906. # differently-typed spool than it was sliced for otherwise records the
  1907. # sliced type in the archive, Print Log and material stats.
  1908. spool_types = _archive_types_from_spools(filament_usage, results)
  1909. if spool_types:
  1910. joined_types = ",".join(spool_types)
  1911. if joined_types != archive.filament_type:
  1912. logger.info(
  1913. "[UsageTracker] 3MF: archive %s filament_type %r -> %r (from inventory spools)",
  1914. archive_id,
  1915. archive.filament_type,
  1916. joined_types,
  1917. )
  1918. archive.filament_type = joined_types
  1919. if unassigned_global_trays:
  1920. from backend.app.services.spool_assignment_notifications import (
  1921. notify_missing_spool_assignments_on_print_complete,
  1922. )
  1923. await notify_missing_spool_assignments_on_print_complete(printer_id, unassigned_global_trays, db, logger)
  1924. return results