usage_tracker.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369
  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 json
  8. import logging
  9. from dataclasses import dataclass, field
  10. from datetime import datetime, timezone
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.models.spool import Spool
  14. from backend.app.models.spool_assignment import SpoolAssignment
  15. from backend.app.models.spool_usage_history import SpoolUsageHistory
  16. logger = logging.getLogger(__name__)
  17. def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
  18. """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
  19. The printer's MQTT mapping field is an array indexed by slicer filament slot
  20. (0-based). Each value uses snow encoding: ams_hw_id * 256 + local_slot.
  21. 65535 means unmapped.
  22. Returns a list of bambuddy global tray IDs (or -1 for unmapped), or None if
  23. no valid mappings found.
  24. """
  25. if not isinstance(mapping_raw, list) or not mapping_raw:
  26. return None
  27. result = []
  28. for value in mapping_raw:
  29. if not isinstance(value, int) or value >= 65535:
  30. result.append(-1)
  31. continue
  32. ams_hw_id = value >> 8
  33. slot = value & 0xFF
  34. if 0 <= ams_hw_id <= 3:
  35. # Regular AMS: sequential global ID
  36. result.append(ams_hw_id * 4 + (slot & 0x03))
  37. elif 128 <= ams_hw_id <= 135:
  38. # AMS-HT: global ID is the hardware ID (one slot per unit)
  39. result.append(ams_hw_id)
  40. elif ams_hw_id in (254, 255):
  41. # External spool
  42. result.append(254 if slot != 255 else 255)
  43. else:
  44. result.append(-1)
  45. # Only return if at least one valid mapping exists
  46. if all(v < 0 for v in result):
  47. return None
  48. return result
  49. def _spool_color_to_hex(rgba: str | None) -> str | None:
  50. """Normalise a ``Spool.rgba`` value (``RRGGBBAA`` hex, no ``#``) to the
  51. ``#RRGGBB`` form archives store in ``filament_color``.
  52. Alpha is dropped — the archive colour list and the Color Distribution
  53. graph treat filament colour as opaque. Returns ``None`` for a missing or
  54. too-short value so the caller can fall back to the 3MF colour.
  55. """
  56. if not rgba:
  57. return None
  58. h = rgba.strip().lstrip("#")
  59. if len(h) < 6:
  60. return None
  61. return "#" + h[:6].upper()
  62. def _archive_colors_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
  63. """Slot-ordered, de-duplicated hex colours for an archive's ``filament_color``,
  64. taken from the inventory spools that actually fed the print (#1494).
  65. The slicer's 3MF carries its own ``filament_colour`` per slot — a value
  66. picked independently of the colour the user curates on the matched
  67. inventory spool. So an archive printed from a ``#000000`` inventory spool
  68. would otherwise show the slicer's near-black ``#161616``. Once usage
  69. tracking has resolved the used slots to spools, the spool colours are the
  70. authoritative source and replace the 3MF values.
  71. Returns ``None`` — leave the 3MF colour untouched — unless *every* slot
  72. with non-zero usage was matched to a spool that carries a colour. A
  73. partial rewrite would silently drop the unmatched slots' colours from the
  74. archive (and the Color Distribution graph), so it is all-or-nothing.
  75. """
  76. 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}
  77. if not used_slots:
  78. return None
  79. slot_color: dict[int, str] = {}
  80. for r in results:
  81. slot_id = r.get("slot_id")
  82. color = r.get("color")
  83. if slot_id is not None and color:
  84. slot_color.setdefault(slot_id, color)
  85. if not used_slots.issubset(slot_color):
  86. return None
  87. ordered: list[str] = []
  88. for slot_id in sorted(used_slots):
  89. color = slot_color[slot_id]
  90. if color not in ordered:
  91. ordered.append(color)
  92. return ordered
  93. def _match_slots_by_color(
  94. filament_usage: list[dict],
  95. ams_raw: dict | list | None,
  96. ) -> list[int] | None:
  97. """Match 3MF filament slots to AMS trays by color.
  98. Fallback mapping for printers that don't provide the MQTT mapping field
  99. or request topic subscription (e.g. A1, A1 Mini, P1S, P2S).
  100. Compares the 3MF slicer filament color (per slot) against each AMS tray's
  101. color to find a unique match. Only returns a mapping if every used slot
  102. matches exactly one tray (no ambiguity).
  103. Args:
  104. filament_usage: List of 3MF slot dicts with 'slot_id', 'color', 'type'
  105. ams_raw: raw_data["ams"] dict or list from printer state
  106. Returns:
  107. List of global tray IDs indexed by slicer slot (0-based), or None.
  108. """
  109. if not filament_usage or not ams_raw:
  110. return None
  111. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  112. if not ams_data:
  113. return None
  114. # Build map of normalized color → list of global tray IDs
  115. color_to_trays: dict[str, list[int]] = {}
  116. for ams_unit in ams_data:
  117. ams_id = int(ams_unit.get("id", 0))
  118. for tray in ams_unit.get("tray", []):
  119. tray_id = int(tray.get("id", 0))
  120. tray_color = tray.get("tray_color", "")
  121. tray_type = tray.get("tray_type", "")
  122. if not tray_color or not tray_type:
  123. continue
  124. # Normalize AMS color: strip alpha (last 2 chars), lowercase
  125. norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
  126. if ams_id >= 128:
  127. global_id = ams_id # AMS-HT
  128. else:
  129. global_id = ams_id * 4 + tray_id
  130. color_to_trays.setdefault(norm, []).append(global_id)
  131. if not color_to_trays:
  132. return None
  133. # Find max slot_id to size the result array
  134. max_slot = max(u.get("slot_id", 0) for u in filament_usage)
  135. if max_slot <= 0:
  136. return None
  137. result = [-1] * max_slot
  138. used_trays: set[int] = set()
  139. for usage in filament_usage:
  140. slot_id = usage.get("slot_id", 0)
  141. if slot_id <= 0:
  142. continue
  143. slot_color = usage.get("color", "").lstrip("#").lower()
  144. if len(slot_color) < 6:
  145. return None # Can't match without a valid color
  146. slot_color = slot_color[:6] # Strip alpha if present
  147. candidates = color_to_trays.get(slot_color, [])
  148. # Filter out trays already claimed by another slot
  149. available = [t for t in candidates if t not in used_trays]
  150. if len(available) != 1:
  151. # Ambiguous (multiple trays with same color) or no match
  152. return None
  153. result[slot_id - 1] = available[0]
  154. used_trays.add(available[0])
  155. # Only return if at least one valid mapping exists
  156. if all(v < 0 for v in result):
  157. return None
  158. logger.info("[UsageTracker] Color-matched slot_to_tray: %s", result)
  159. return result
  160. @dataclass
  161. class PrintSession:
  162. printer_id: int
  163. print_name: str
  164. started_at: datetime
  165. tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
  166. # tray_now at print start (correct value, unlike at completion where it's 255)
  167. tray_now_at_start: int = -1
  168. # Snapshot of spool assignments at print start: {(ams_id, tray_id): spool_id}
  169. # Prevents usage loss when on_ams_change unlinks a spool mid-print
  170. spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
  171. # AMS mapping from print command (captured at start, needed when auto-archive is off)
  172. ams_mapping: list[int] | None = None
  173. # Module-level storage, keyed by printer_id
  174. _active_sessions: dict[int, PrintSession] = {}
  175. def _to_epoch_seconds(value: datetime | None) -> float | None:
  176. """Convert datetime to epoch seconds, assuming UTC for naive values."""
  177. if value is None:
  178. return None
  179. dt = value
  180. if dt.tzinfo is None:
  181. dt = dt.replace(tzinfo=timezone.utc)
  182. return dt.timestamp()
  183. async def _resolve_spool_id_for_tray(
  184. printer_id: int,
  185. ams_id: int,
  186. tray_id: int,
  187. db: AsyncSession,
  188. spool_assignments_snapshot: dict[tuple[int, int], int] | None = None,
  189. print_started_at: datetime | None = None,
  190. ) -> int | None:
  191. """Resolve spool ID for a tray with safe support for mid-print reassignment.
  192. Resolution order:
  193. 1. If snapshot exists and live assignment changed *during this print*, use live spool.
  194. 2. Otherwise use snapshot spool when available.
  195. 3. Fall back to live assignment.
  196. """
  197. key = (ams_id, tray_id)
  198. snapshot_spool_id = spool_assignments_snapshot.get(key) if spool_assignments_snapshot else None
  199. # Backward-compatible fast path: if we have a snapshot but no print-start
  200. # timestamp, preserve legacy behavior and avoid extra DB lookups.
  201. if snapshot_spool_id is not None and print_started_at is None:
  202. return snapshot_spool_id
  203. result = await db.execute(
  204. select(SpoolAssignment).where(
  205. SpoolAssignment.printer_id == printer_id,
  206. SpoolAssignment.ams_id == ams_id,
  207. SpoolAssignment.tray_id == tray_id,
  208. )
  209. )
  210. live_assignment = result.scalar_one_or_none()
  211. if snapshot_spool_id is not None:
  212. if live_assignment and live_assignment.spool_id != snapshot_spool_id:
  213. live_created_ts = _to_epoch_seconds(getattr(live_assignment, "created_at", None))
  214. started_ts = _to_epoch_seconds(print_started_at)
  215. if live_created_ts is not None and started_ts is not None and live_created_ts >= started_ts:
  216. logger.info(
  217. "[UsageTracker] Assignment changed during print for printer %d AMS%d-T%d: snapshot spool %d -> live spool %d",
  218. printer_id,
  219. ams_id,
  220. tray_id,
  221. snapshot_spool_id,
  222. live_assignment.spool_id,
  223. )
  224. return live_assignment.spool_id
  225. return snapshot_spool_id
  226. if live_assignment:
  227. return live_assignment.spool_id
  228. return None
  229. async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
  230. """Capture AMS tray remain% and spool assignments at print start."""
  231. state = printer_manager.get_status(printer_id)
  232. if not state or not state.raw_data:
  233. logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
  234. return
  235. ams_raw = state.raw_data.get("ams", [])
  236. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  237. tray_remain_start: dict[tuple[int, int], int] = {}
  238. skipped_invalid: list[str] = []
  239. for ams_unit in ams_data:
  240. ams_id = int(ams_unit.get("id", 0))
  241. for tray in ams_unit.get("tray", []):
  242. tray_id = int(tray.get("id", 0))
  243. remain = tray.get("remain", -1)
  244. if isinstance(remain, int) and 0 <= remain <= 100:
  245. tray_remain_start[(ams_id, tray_id)] = remain
  246. else:
  247. skipped_invalid.append(f"AMS{ams_id}-T{tray_id}(remain={remain})")
  248. # Also capture VT (external) tray remain% — these are separate from AMS units
  249. vt_tray_raw = state.raw_data.get("vt_tray") or []
  250. if isinstance(vt_tray_raw, dict):
  251. vt_tray_raw = [vt_tray_raw]
  252. for vt in vt_tray_raw:
  253. if not isinstance(vt, dict):
  254. continue
  255. vt_id = int(vt.get("id", 254))
  256. # VT tray id 254 → (ams_id=255, tray_id=0), id 255 → (ams_id=255, tray_id=1)
  257. vt_tray_id = vt_id - 254
  258. remain = vt.get("remain", -1)
  259. if isinstance(remain, int) and 0 <= remain <= 100:
  260. tray_remain_start[(255, vt_tray_id)] = remain
  261. else:
  262. skipped_invalid.append(f"VT{vt_id}(remain={remain})")
  263. if skipped_invalid:
  264. logger.info(
  265. "[UsageTracker] Skipped trays with invalid remain%% for printer %d: %s",
  266. printer_id,
  267. ", ".join(skipped_invalid),
  268. )
  269. if not ams_data and not vt_tray_raw:
  270. logger.debug("[UsageTracker] No AMS or VT tray data for printer %d, skipping", printer_id)
  271. return
  272. print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
  273. # Capture tray_now at print start (reliable, unlike at completion where it's 255)
  274. tray_now_at_start = state.tray_now if state else -1
  275. # --- Diagnostic logging: dump mapping-related MQTT fields at print start ---
  276. # This helps us understand what each printer model reports for slot-to-tray mapping.
  277. mapping_field = state.raw_data.get("mapping")
  278. logger.info(
  279. "[UsageTracker] PRINT START printer %d: mapping=%s, tray_now=%d, last_loaded_tray=%s",
  280. printer_id,
  281. mapping_field,
  282. tray_now_at_start,
  283. getattr(state, "last_loaded_tray", "N/A"),
  284. )
  285. # Log all raw_data keys containing "map" or "ams" for discovery
  286. map_keys = {k: state.raw_data[k] for k in state.raw_data if "map" in k.lower()}
  287. if map_keys:
  288. logger.info("[UsageTracker] PRINT START printer %d: mapping-related keys: %s", printer_id, map_keys)
  289. # Log per-tray summary: tray_now, tray_tar, tray_type, tray_color for each slot
  290. for ams_unit in ams_data:
  291. ams_id = int(ams_unit.get("id", 0))
  292. tray_summary = []
  293. for tray in ams_unit.get("tray", []):
  294. tray_summary.append(
  295. f"T{tray.get('id', '?')}(type={tray.get('tray_type', '')}, "
  296. f"color={tray.get('tray_color', '')}, "
  297. f"now={ams_raw.get('tray_now', '?') if isinstance(ams_raw, dict) else '?'}, "
  298. f"tar={ams_raw.get('tray_tar', '?') if isinstance(ams_raw, dict) else '?'})"
  299. )
  300. logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
  301. # Snapshot spool assignments so usage isn't lost if on_ams_change unlinks mid-print
  302. spool_assignments: dict[tuple[int, int], int] = {}
  303. if db:
  304. assign_result = await db.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer_id))
  305. for assignment in assign_result.scalars().all():
  306. spool_assignments[(assignment.ams_id, assignment.tray_id)] = assignment.spool_id
  307. if spool_assignments:
  308. logger.info(
  309. "[UsageTracker] Snapshotted %d spool assignments for printer %d: %s",
  310. len(spool_assignments),
  311. printer_id,
  312. {f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
  313. )
  314. # Always create session (even without valid remain data) so print_name
  315. # is available at completion for 3MF-based tracking
  316. session = PrintSession(
  317. printer_id=printer_id,
  318. print_name=print_name,
  319. started_at=datetime.now(timezone.utc),
  320. tray_remain_start=tray_remain_start,
  321. tray_now_at_start=tray_now_at_start,
  322. spool_assignments=spool_assignments,
  323. ams_mapping=data.get("ams_mapping"),
  324. )
  325. _active_sessions[printer_id] = session
  326. if tray_remain_start:
  327. logger.info(
  328. "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
  329. printer_id,
  330. len(tray_remain_start),
  331. {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
  332. )
  333. else:
  334. logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
  335. async def on_print_complete(
  336. printer_id: int,
  337. data: dict,
  338. printer_manager,
  339. db: AsyncSession,
  340. archive_id: int | None = None,
  341. ams_mapping: list[int] | None = None,
  342. ) -> list[dict]:
  343. """Compute consumption deltas and update spool weight_used/last_used.
  344. Uses two tracking strategies in priority order:
  345. 1. 3MF per-filament estimates (primary) — precise slicer data for all spools
  346. 2. AMS remain% delta (fallback) — only for trays not already handled by 3MF
  347. Returns a list of dicts describing what was logged (for WebSocket broadcast).
  348. """
  349. from sqlalchemy import select
  350. from backend.app.api.routes.settings import get_setting
  351. from backend.app.models.spool_usage_history import SpoolUsageHistory
  352. session = _active_sessions.pop(printer_id, None)
  353. status = data.get("status", "completed")
  354. results = []
  355. handled_trays: set[tuple[int, int]] = set()
  356. # Fetch default filament cost from settings for fallback
  357. default_cost_str = await get_setting(db, "default_filament_cost")
  358. default_filament_cost = float(default_cost_str) if default_cost_str else 0.0
  359. # Fall back to ams_mapping captured at print start (needed when auto-archive is off
  360. # and the caller can't retrieve the mapping from _print_ams_mappings without archive_id)
  361. if not ams_mapping and session and session.ams_mapping:
  362. ams_mapping = session.ams_mapping
  363. logger.info(
  364. "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s",
  365. printer_id,
  366. archive_id,
  367. "yes" if session else "no",
  368. ams_mapping,
  369. )
  370. # --- Diagnostic logging: dump mapping-related MQTT fields at print completion ---
  371. state = printer_manager.get_status(printer_id)
  372. if state and state.raw_data:
  373. logger.info(
  374. "[UsageTracker] PRINT COMPLETE printer %d: mapping=%s, tray_now=%s, last_loaded_tray=%s",
  375. printer_id,
  376. state.raw_data.get("mapping"),
  377. state.tray_now,
  378. getattr(state, "last_loaded_tray", "N/A"),
  379. )
  380. # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
  381. print_name = (
  382. (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
  383. )
  384. # When auto-archive is disabled (archive_id=None), try to find a 3MF by filename
  385. # from the library or previous archives so we can still track filament usage.
  386. threemf_path = None
  387. if not archive_id:
  388. from backend.app.core.config import settings as app_settings
  389. search_filename = data.get("filename") or data.get("subtask_name") or (session.print_name if session else "")
  390. if search_filename:
  391. threemf_path = await _find_3mf_by_filename(printer_id, search_filename, db, app_settings.base_dir)
  392. if archive_id or threemf_path:
  393. threemf_results = await _track_from_3mf(
  394. printer_id,
  395. archive_id,
  396. status,
  397. print_name,
  398. handled_trays,
  399. printer_manager,
  400. db,
  401. ams_mapping=ams_mapping,
  402. tray_now_at_start=session.tray_now_at_start if session else -1,
  403. last_progress=data.get("last_progress", 0.0),
  404. last_layer_num=data.get("last_layer_num", 0),
  405. default_filament_cost=default_filament_cost,
  406. spool_assignments=session.spool_assignments if session else None,
  407. print_started_at=session.started_at if session else None,
  408. threemf_path=threemf_path,
  409. )
  410. results.extend(threemf_results)
  411. # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---
  412. if session and session.tray_remain_start:
  413. state = printer_manager.get_status(printer_id)
  414. if state and state.raw_data:
  415. ams_raw = state.raw_data.get("ams", [])
  416. ams_data = (
  417. ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  418. )
  419. # Build set of trays actually involved in this print (#1269).
  420. # Without this guard, swapping a spool in an UNUSED slot mid-print
  421. # makes that slot's remain% drop to 0, which the fallback below
  422. # would otherwise charge to the originally-assigned spool.
  423. def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
  424. if global_tray_id >= 254:
  425. return (255, global_tray_id - 254)
  426. if global_tray_id >= 128:
  427. return (global_tray_id, 0)
  428. return (global_tray_id // 4, global_tray_id % 4)
  429. print_used_keys: set[tuple[int, int]] = set()
  430. if ams_mapping:
  431. for gid in ams_mapping:
  432. if isinstance(gid, int) and gid >= 0:
  433. print_used_keys.add(_global_to_ams_key(gid))
  434. for change in getattr(state, "tray_change_log", None) or []:
  435. if isinstance(change, (tuple, list)) and len(change) >= 1:
  436. gid = change[0]
  437. if isinstance(gid, int) and gid >= 0:
  438. print_used_keys.add(_global_to_ams_key(gid))
  439. if session.tray_now_at_start is not None and session.tray_now_at_start >= 0:
  440. print_used_keys.add(_global_to_ams_key(session.tray_now_at_start))
  441. # Collect all trays to check: AMS trays + VT (external) trays
  442. # Each entry: (ams_id_for_assignment, tray_id_for_assignment, current_remain, label)
  443. trays_to_check: list[tuple[int, int, int, str]] = []
  444. for ams_unit in ams_data:
  445. ams_id = int(ams_unit.get("id", 0))
  446. for tray in ams_unit.get("tray", []):
  447. tray_id = int(tray.get("id", 0))
  448. remain = tray.get("remain", -1)
  449. trays_to_check.append((ams_id, tray_id, remain, f"AMS{ams_id}-T{tray_id}"))
  450. # VT (external) trays — same remain% delta logic
  451. vt_tray_raw = state.raw_data.get("vt_tray") or []
  452. if isinstance(vt_tray_raw, dict):
  453. vt_tray_raw = [vt_tray_raw]
  454. for vt in vt_tray_raw:
  455. if not isinstance(vt, dict):
  456. continue
  457. vt_id = int(vt.get("id", 254))
  458. vt_tray_id = vt_id - 254 # 254→0, 255→1
  459. remain = vt.get("remain", -1)
  460. trays_to_check.append((255, vt_tray_id, remain, f"VT{vt_id}"))
  461. for assign_ams_id, assign_tray_id, current_remain, tray_label in trays_to_check:
  462. key = (assign_ams_id, assign_tray_id)
  463. if key in handled_trays:
  464. continue # Already tracked via 3MF
  465. if key not in session.tray_remain_start:
  466. continue
  467. # Skip trays the print never touched. Only enforce when we have
  468. # evidence of which trays the print used; if print_used_keys is
  469. # empty (no mapping, no change log, no tray_now_at_start) keep
  470. # the legacy behavior of scanning every tray.
  471. if print_used_keys and key not in print_used_keys:
  472. logger.info(
  473. "[UsageTracker] %s: not in print mapping/tray_change_log — skipping fallback for printer %d",
  474. tray_label,
  475. printer_id,
  476. )
  477. continue
  478. if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
  479. logger.info(
  480. "[UsageTracker] %s: invalid remain%% at completion (%s), skipping fallback for printer %d",
  481. tray_label,
  482. current_remain,
  483. printer_id,
  484. )
  485. continue
  486. start_remain = session.tray_remain_start[key]
  487. delta_pct = start_remain - current_remain
  488. if delta_pct <= 0:
  489. continue # No consumption or tray was refilled
  490. spool_id = await _resolve_spool_id_for_tray(
  491. printer_id=printer_id,
  492. ams_id=assign_ams_id,
  493. tray_id=assign_tray_id,
  494. db=db,
  495. spool_assignments_snapshot=session.spool_assignments,
  496. print_started_at=session.started_at,
  497. )
  498. if spool_id is None:
  499. logger.info(
  500. "[UsageTracker] %s: no spool assigned, skipping fallback for printer %d",
  501. tray_label,
  502. printer_id,
  503. )
  504. continue
  505. # Load spool
  506. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  507. spool = spool_result.scalar_one_or_none()
  508. if not spool:
  509. continue
  510. # Compute weight consumed
  511. weight_grams = (delta_pct / 100.0) * spool.label_weight
  512. # Update spool
  513. spool.weight_used = (spool.weight_used or 0) + weight_grams
  514. spool.last_used = datetime.now(timezone.utc)
  515. # Calculate cost for this usage
  516. cost = None
  517. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  518. if cost_per_kg > 0:
  519. cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
  520. # Insert usage history record
  521. history = SpoolUsageHistory(
  522. spool_id=spool.id,
  523. printer_id=printer_id,
  524. print_name=session.print_name,
  525. weight_used=round(weight_grams, 1),
  526. percent_used=delta_pct,
  527. status=status,
  528. cost=cost,
  529. archive_id=archive_id,
  530. )
  531. db.add(history)
  532. handled_trays.add(key)
  533. results.append(
  534. {
  535. "spool_id": spool.id,
  536. "weight_used": round(weight_grams, 1),
  537. "percent_used": delta_pct,
  538. "ams_id": assign_ams_id,
  539. "tray_id": assign_tray_id,
  540. "material": spool.material,
  541. "cost": cost,
  542. # AMS remain%-delta fallback has no 3MF slot — slot_id
  543. # stays None so it is excluded from the colour rewrite.
  544. "slot_id": None,
  545. "color": _spool_color_to_hex(spool.rgba),
  546. }
  547. )
  548. logger.info(
  549. "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d %s (AMS fallback, %s)",
  550. spool.id,
  551. weight_grams,
  552. delta_pct,
  553. printer_id,
  554. tray_label,
  555. status,
  556. )
  557. if results:
  558. await db.commit()
  559. # --- Update PrintArchive.cost from THIS print session only ---
  560. #
  561. # Cover any filament weight that wasn't tracked by an inventory spool with
  562. # the global default rate (#1344). Without this, a multi-color print where
  563. # only some AMS trays are mapped to inventory spools would record only the
  564. # mapped slots' share — e.g. $0.01 for a 110g print when 3 of 4 trays had
  565. # no spool record. The initial cost set by archive.py (total grams *
  566. # primary cost_per_kg) is fine on its own, but this block overwrites it,
  567. # so the overwrite must reconstruct the whole-print cost.
  568. if archive_id and results:
  569. from sqlalchemy import func, select
  570. from backend.app.models.archive import PrintArchive
  571. from backend.app.models.print_log import PrintLogEntry
  572. archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  573. archive = archive_result.scalar_one_or_none()
  574. if archive:
  575. total_cost = sum(r.get("cost", 0) or 0 for r in results)
  576. tracked_grams = sum(r.get("weight_used", 0) or 0 for r in results)
  577. archive_grams = archive.filament_used_grams or 0
  578. untracked_grams = max(0.0, archive_grams - tracked_grams)
  579. if untracked_grams > 0 and default_filament_cost > 0:
  580. total_cost += (untracked_grams / 1000.0) * default_filament_cost
  581. if total_cost > 0:
  582. # Only overwrite archive.cost on the first run. Reprint actuals
  583. # live in PrintLogEntry; the archive card keeps the first run's
  584. # cost so a failed reprint doesn't visually clobber a successful
  585. # 100 g/$X print with a 10 g/$X/10 partial (#1378).
  586. _existing_runs_result = await db.execute(
  587. select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id)
  588. )
  589. _existing_runs = _existing_runs_result.scalar()
  590. if not _existing_runs:
  591. archive.cost = round(total_cost, 2)
  592. await db.commit()
  593. return results
  594. async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
  595. """Try to find a 3MF file from library or a previous archive when the current archive has none.
  596. This handles fallback archives (FTP download failed) where the 3MF may already exist
  597. locally from a library upload or a previous successful print of the same file.
  598. """
  599. from pathlib import Path
  600. from backend.app.models.archive import PrintArchive
  601. from backend.app.models.library import LibraryFile
  602. # Derive search name from archive filename (e.g. "benchy.3mf" or "benchy.gcode.3mf")
  603. search_name = archive.filename or archive.print_name
  604. if not search_name:
  605. return None
  606. # Normalize: strip path parts, get base name
  607. search_name = search_name.split("/")[-1]
  608. search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
  609. if not search_base:
  610. return None
  611. # 1. Try library files matching the name (match base name at file boundary)
  612. try:
  613. lib_result = await db.execute(
  614. LibraryFile.active()
  615. .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
  616. .where(LibraryFile.file_path.ilike("%.3mf"))
  617. .order_by(LibraryFile.created_at.desc())
  618. .limit(3)
  619. )
  620. for lib_file in lib_result.scalars().all():
  621. lib_path = Path(lib_file.file_path)
  622. candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
  623. if candidate.exists() and candidate.suffix == ".3mf":
  624. logger.info("[UsageTracker] 3MF fallback: found library file %s for archive %s", candidate, archive.id)
  625. return candidate
  626. except Exception as e:
  627. logger.debug("[UsageTracker] 3MF fallback: library lookup failed: %s", e)
  628. # 2. Try previous archives with the same filename that have a valid file_path
  629. try:
  630. prev_result = await db.execute(
  631. select(PrintArchive)
  632. .where(PrintArchive.id != archive.id)
  633. .where(PrintArchive.printer_id == archive.printer_id)
  634. .where(PrintArchive.file_path != "")
  635. .where(PrintArchive.file_path.isnot(None))
  636. .where(
  637. PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
  638. )
  639. .order_by(PrintArchive.created_at.desc())
  640. .limit(3)
  641. )
  642. for prev_archive in prev_result.scalars().all():
  643. candidate = base_dir / prev_archive.file_path
  644. if candidate.exists() and candidate.suffix == ".3mf":
  645. logger.info(
  646. "[UsageTracker] 3MF fallback: found previous archive %s file for archive %s",
  647. prev_archive.id,
  648. archive.id,
  649. )
  650. return candidate
  651. except Exception as e:
  652. logger.debug("[UsageTracker] 3MF fallback: previous archive lookup failed: %s", e)
  653. return None
  654. async def _find_3mf_by_filename(
  655. printer_id: int,
  656. filename: str,
  657. db: AsyncSession,
  658. base_dir,
  659. ):
  660. """Find a 3MF file by filename from library or previous archives.
  661. Used when auto-archive is disabled and there's no archive_id, but we still
  662. need the 3MF slicer data for filament usage tracking.
  663. """
  664. from pathlib import Path
  665. from backend.app.models.archive import PrintArchive
  666. from backend.app.models.library import LibraryFile
  667. search_name = filename.split("/")[-1] if "/" in filename else filename
  668. search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
  669. if not search_base:
  670. return None
  671. # 1. Try library files matching the name
  672. try:
  673. lib_result = await db.execute(
  674. LibraryFile.active()
  675. .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
  676. .where(LibraryFile.file_path.ilike("%.3mf"))
  677. .order_by(LibraryFile.created_at.desc())
  678. .limit(3)
  679. )
  680. for lib_file in lib_result.scalars().all():
  681. lib_path = Path(lib_file.file_path)
  682. candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
  683. if candidate.exists() and candidate.suffix == ".3mf":
  684. logger.info("[UsageTracker] 3MF (no-archive): found library file %s for '%s'", candidate, filename)
  685. return candidate
  686. except Exception as e:
  687. logger.debug("[UsageTracker] 3MF (no-archive): library lookup failed: %s", e)
  688. # 2. Try previous archives with a valid 3MF file_path
  689. try:
  690. prev_result = await db.execute(
  691. select(PrintArchive)
  692. .where(PrintArchive.printer_id == printer_id)
  693. .where(PrintArchive.file_path != "")
  694. .where(PrintArchive.file_path.isnot(None))
  695. .where(
  696. PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
  697. )
  698. .order_by(PrintArchive.created_at.desc())
  699. .limit(3)
  700. )
  701. for prev_archive in prev_result.scalars().all():
  702. candidate = base_dir / prev_archive.file_path
  703. if candidate.exists() and candidate.suffix == ".3mf":
  704. logger.info(
  705. "[UsageTracker] 3MF (no-archive): found previous archive %s file for '%s'",
  706. prev_archive.id,
  707. filename,
  708. )
  709. return candidate
  710. except Exception as e:
  711. logger.debug("[UsageTracker] 3MF (no-archive): previous archive lookup failed: %s", e)
  712. return None
  713. async def _track_from_3mf(
  714. printer_id: int,
  715. archive_id: int | None,
  716. status: str,
  717. print_name: str,
  718. handled_trays: set[tuple[int, int]],
  719. printer_manager,
  720. db: AsyncSession,
  721. ams_mapping: list[int] | None = None,
  722. tray_now_at_start: int = -1,
  723. last_progress: float = 0.0,
  724. last_layer_num: int = 0,
  725. default_filament_cost: float = 0.0,
  726. spool_assignments: dict[tuple[int, int], int] | None = None,
  727. print_started_at: datetime | None = None,
  728. threemf_path=None,
  729. ) -> list[dict]:
  730. """Track usage from 3MF per-filament slicer data (primary path).
  731. Uses slicer-estimated filament weight for all spools (BL and non-BL).
  732. For partial prints (failed/aborted), tries per-layer gcode data first,
  733. then falls back to linear scaling by progress.
  734. When archive_id is None (auto-archive disabled), a pre-resolved threemf_path
  735. can be provided to still track filament usage from slicer data.
  736. Slot-to-tray mapping priority:
  737. 1. Stored ams_mapping from print command (reprints/direct prints)
  738. 2. MQTT mapping field from printer state (universal, all print sources)
  739. 3. Queue item ams_mapping (for queue-initiated prints)
  740. 4. tray_now from printer state (for single-filament non-queue prints)
  741. 5. Position-based default using sorted available tray IDs (handles external spools)
  742. 6. Default mapping: slot_id - 1 = global_tray_id (last resort)
  743. """
  744. from pathlib import Path
  745. from backend.app.core.config import settings as app_settings
  746. from backend.app.models.archive import PrintArchive
  747. from backend.app.models.print_queue import PrintQueueItem
  748. from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
  749. file_path: Path | None = threemf_path
  750. archive: PrintArchive | None = None
  751. if file_path is None and archive_id:
  752. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  753. archive = result.scalar_one_or_none()
  754. if not archive:
  755. logger.info("[UsageTracker] 3MF: archive %s not found, skipping", archive_id)
  756. return []
  757. # Try archive's own file_path first
  758. if archive.file_path:
  759. candidate = app_settings.base_dir / archive.file_path
  760. if candidate.exists():
  761. file_path = candidate
  762. # Fallback: find 3MF from library or a previous archive with the same filename
  763. if file_path is None:
  764. file_path = await _resolve_3mf_fallback(archive, db, app_settings.base_dir)
  765. if file_path is None:
  766. logger.info("[UsageTracker] 3MF: no file available for archive %s, skipping", archive_id)
  767. return []
  768. filament_usage = extract_filament_usage_from_3mf(file_path)
  769. if not filament_usage:
  770. logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
  771. return []
  772. logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
  773. # --- Resolve slot-to-tray mapping ---
  774. mapping_source = None
  775. # 1. Use stored ams_mapping from the print command (reprints/direct prints)
  776. slot_to_tray = ams_mapping
  777. if slot_to_tray:
  778. mapping_source = "print_cmd"
  779. # 2. Try MQTT mapping field from printer state (universal, all print sources)
  780. if not slot_to_tray:
  781. state = printer_manager.get_status(printer_id)
  782. raw_data = getattr(state, "raw_data", None) if state else None
  783. if raw_data:
  784. mqtt_mapping = raw_data.get("mapping")
  785. decoded = _decode_mqtt_mapping(mqtt_mapping)
  786. if decoded:
  787. slot_to_tray = decoded
  788. mapping_source = "mqtt"
  789. # 3. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
  790. if not slot_to_tray and archive_id:
  791. queue_result = await db.execute(
  792. select(PrintQueueItem)
  793. .where(PrintQueueItem.archive_id == archive_id)
  794. .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
  795. )
  796. queue_item = queue_result.scalar_one_or_none()
  797. if queue_item and queue_item.ams_mapping:
  798. try:
  799. slot_to_tray = json.loads(queue_item.ams_mapping)
  800. mapping_source = "queue"
  801. except (json.JSONDecodeError, TypeError):
  802. pass
  803. # 4. Color-match 3MF filament slots to AMS trays (for printers without mapping field)
  804. if not slot_to_tray:
  805. state = printer_manager.get_status(printer_id)
  806. raw_data = getattr(state, "raw_data", None) if state else None
  807. if raw_data:
  808. matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
  809. if matched:
  810. slot_to_tray = matched
  811. mapping_source = "color_match"
  812. logger.info(
  813. "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
  814. slot_to_tray,
  815. mapping_source or "none",
  816. )
  817. # 5. For single-filament non-queue prints, use tray_now from printer state
  818. # Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now
  819. # > last_loaded_tray > vt_tray check
  820. #
  821. # tray_change_log evidence wins over slot_to_tray when present: if the
  822. # printer fed from multiple trays mid-print (AMS auto-fallback when one
  823. # spool runs out, #957), the slicer's mapping captured at print start
  824. # is stale and needs to be replaced with per-layer split attribution.
  825. nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
  826. tray_now_override: int | None = None
  827. tray_changes: list[tuple[int, int]] = [] # [(global_tray_id, layer_num), ...]
  828. state = printer_manager.get_status(printer_id) if len(nonzero_slots) == 1 else None
  829. if state is not None:
  830. tray_changes = getattr(state, "tray_change_log", []) or []
  831. if len(tray_changes) > 1:
  832. # Multi-tray usage detected — splitting takes over regardless of slot_to_tray.
  833. logger.info("[UsageTracker] 3MF: tray change log: %s (will split weight)", tray_changes)
  834. elif not slot_to_tray and len(nonzero_slots) == 1:
  835. if 0 <= tray_now_at_start <= 254:
  836. tray_now_override = tray_now_at_start
  837. logger.info("[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)", tray_now_at_start)
  838. elif state and 0 <= state.tray_now <= 254:
  839. tray_now_override = state.tray_now
  840. logger.info("[UsageTracker] 3MF: using current tray_now=%d", state.tray_now)
  841. elif state and 0 <= state.last_loaded_tray <= 253:
  842. tray_now_override = state.last_loaded_tray
  843. logger.info("[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)", state.last_loaded_tray)
  844. elif state and state.tray_now == 255:
  845. # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
  846. vt_tray = state.raw_data.get("vt_tray") or []
  847. if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
  848. tray_now_override = state.tray_now
  849. logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
  850. if tray_now_override is None:
  851. logger.info(
  852. "[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",
  853. tray_now_at_start,
  854. state.tray_now if state else "N/A",
  855. state.last_loaded_tray if state else "N/A",
  856. )
  857. # Scale factor for partial prints (failed/aborted)
  858. if status == "completed":
  859. scale = 1.0
  860. else:
  861. state = printer_manager.get_status(printer_id)
  862. progress = state.progress if state else 0
  863. # Firmware resets progress to 0 on cancel — use last valid progress captured during print
  864. if progress <= 0 and last_progress > 0:
  865. progress = last_progress
  866. logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
  867. scale = max(0.0, min(progress / 100.0, 1.0))
  868. # Per-layer gcode accuracy for partial prints
  869. layer_grams: dict[int, float] | None = None
  870. if status != "completed":
  871. state = printer_manager.get_status(printer_id)
  872. current_layer = state.layer_num if state else 0
  873. # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
  874. if current_layer <= 0 and last_layer_num > 0:
  875. current_layer = last_layer_num
  876. logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
  877. if current_layer > 0:
  878. try:
  879. from backend.app.utils.threemf_tools import (
  880. extract_filament_properties_from_3mf,
  881. extract_layer_filament_usage_from_3mf,
  882. get_cumulative_usage_at_layer,
  883. mm_to_grams,
  884. )
  885. layer_usage = extract_layer_filament_usage_from_3mf(file_path)
  886. if layer_usage:
  887. cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
  888. filament_props = extract_filament_properties_from_3mf(file_path)
  889. layer_grams = {}
  890. for filament_id, mm_used in cumulative_mm.items():
  891. slot_id = filament_id + 1 # 0-based to 1-based
  892. props = filament_props.get(slot_id, {})
  893. density = props.get("density", 1.24)
  894. diameter = props.get("diameter", 1.75)
  895. layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
  896. except Exception:
  897. pass # Fall back to linear scaling
  898. results = []
  899. for usage in filament_usage:
  900. slot_id = usage.get("slot_id", 0)
  901. used_g = usage.get("used_g", 0)
  902. if used_g <= 0:
  903. continue
  904. # --- Mid-print tray switch: split weight across trays ---
  905. if len(tray_changes) > 1:
  906. # Compute total weight for this slot (same logic as normal path)
  907. if layer_grams and slot_id in layer_grams:
  908. total_weight = layer_grams[slot_id]
  909. else:
  910. total_weight = used_g * scale
  911. if total_weight <= 0:
  912. continue
  913. # Extract per-layer gcode for segment splitting
  914. split_layer_usage = None
  915. split_props: dict = {}
  916. try:
  917. from backend.app.utils.threemf_tools import (
  918. extract_filament_properties_from_3mf,
  919. extract_layer_filament_usage_from_3mf,
  920. get_cumulative_usage_at_layer,
  921. mm_to_grams,
  922. )
  923. split_layer_usage = extract_layer_filament_usage_from_3mf(file_path)
  924. filament_props = extract_filament_properties_from_3mf(file_path)
  925. split_props = filament_props.get(slot_id, {})
  926. except Exception:
  927. pass # Fall back to linear splitting
  928. density = split_props.get("density", 1.24)
  929. diameter = split_props.get("diameter", 1.75)
  930. filament_id = slot_id - 1 # 0-based for gcode
  931. sum_previous = 0.0
  932. for seg_idx, (tray_global, seg_start_layer) in enumerate(tray_changes):
  933. is_last = seg_idx + 1 >= len(tray_changes)
  934. if is_last:
  935. # Last segment: remainder to avoid rounding drift
  936. segment_grams = total_weight - sum_previous
  937. elif split_layer_usage:
  938. seg_end_layer = tray_changes[seg_idx + 1][1]
  939. mm_at_start = get_cumulative_usage_at_layer(split_layer_usage, seg_start_layer).get(filament_id, 0)
  940. mm_at_end = get_cumulative_usage_at_layer(split_layer_usage, seg_end_layer).get(filament_id, 0)
  941. segment_grams = mm_to_grams(mm_at_end - mm_at_start, diameter, density)
  942. else:
  943. # No per-layer data: linear fallback by layer ratio
  944. seg_end_layer = tray_changes[seg_idx + 1][1]
  945. total_layers = state.total_layers if state else 0
  946. if total_layers > 0:
  947. segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
  948. else:
  949. # Can't compute ratio — assign all to last segment
  950. segment_grams = 0.0
  951. sum_previous += segment_grams
  952. if segment_grams <= 0:
  953. continue
  954. # Convert global tray ID to (ams_id, tray_id)
  955. if tray_global >= 254:
  956. seg_ams_id = 255
  957. seg_tray_id = tray_global - 254
  958. elif tray_global >= 128:
  959. seg_ams_id = tray_global
  960. seg_tray_id = 0
  961. else:
  962. seg_ams_id = tray_global // 4
  963. seg_tray_id = tray_global % 4
  964. seg_key = (seg_ams_id, seg_tray_id)
  965. if seg_key in handled_trays:
  966. continue
  967. logger.info(
  968. "[UsageTracker] 3MF split: segment %d tray=%d (AMS%d-T%d) layers %d-%s -> %.1fg",
  969. seg_idx,
  970. tray_global,
  971. seg_ams_id,
  972. seg_tray_id,
  973. seg_start_layer,
  974. tray_changes[seg_idx + 1][1] if not is_last else "end",
  975. segment_grams,
  976. )
  977. seg_spool_id = await _resolve_spool_id_for_tray(
  978. printer_id=printer_id,
  979. ams_id=seg_ams_id,
  980. tray_id=seg_tray_id,
  981. db=db,
  982. spool_assignments_snapshot=spool_assignments,
  983. print_started_at=print_started_at,
  984. )
  985. if seg_spool_id is None:
  986. logger.info(
  987. "[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
  988. printer_id,
  989. seg_ams_id,
  990. seg_tray_id,
  991. )
  992. continue
  993. spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))
  994. spool = spool_result.scalar_one_or_none()
  995. if not spool:
  996. continue
  997. spool.weight_used = (spool.weight_used or 0) + segment_grams
  998. spool.last_used = datetime.now(timezone.utc)
  999. percent = round(segment_grams / (spool.label_weight or 1000) * 100)
  1000. cost = None
  1001. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  1002. if cost_per_kg > 0:
  1003. cost = round((segment_grams / 1000.0) * cost_per_kg, 2)
  1004. history = SpoolUsageHistory(
  1005. spool_id=spool.id,
  1006. printer_id=printer_id,
  1007. print_name=print_name,
  1008. weight_used=round(segment_grams, 1),
  1009. percent_used=percent,
  1010. status=status,
  1011. cost=cost,
  1012. archive_id=archive_id,
  1013. )
  1014. db.add(history)
  1015. handled_trays.add(seg_key)
  1016. results.append(
  1017. {
  1018. "spool_id": spool.id,
  1019. "weight_used": round(segment_grams, 1),
  1020. "percent_used": percent,
  1021. "ams_id": seg_ams_id,
  1022. "tray_id": seg_tray_id,
  1023. "material": spool.material,
  1024. "cost": cost,
  1025. "slot_id": slot_id,
  1026. "color": _spool_color_to_hex(spool.rgba),
  1027. }
  1028. )
  1029. logger.info(
  1030. "[UsageTracker] Spool %d consumed %.1fg (3MF split seg%d) on printer %d AMS%d-T%d (%s)",
  1031. spool.id,
  1032. segment_grams,
  1033. seg_idx,
  1034. printer_id,
  1035. seg_ams_id,
  1036. seg_tray_id,
  1037. status,
  1038. )
  1039. continue # Skip normal single-tray processing for this slot
  1040. # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping
  1041. if tray_now_override is not None:
  1042. # Single-filament non-queue print: use actual tray from printer state
  1043. global_tray_id = tray_now_override
  1044. else:
  1045. # Explicit mapping (print command, MQTT, queue, color match)
  1046. global_tray_id = None
  1047. if slot_to_tray and slot_id <= len(slot_to_tray):
  1048. mapped = slot_to_tray[slot_id - 1]
  1049. if isinstance(mapped, int) and mapped >= 0:
  1050. global_tray_id = mapped
  1051. # Position-based default: sort available tray IDs so external spools (254/255)
  1052. # naturally follow standard AMS trays, matching slicer slot numbering
  1053. if global_tray_id is None:
  1054. _state = printer_manager.get_status(printer_id)
  1055. _raw = getattr(_state, "raw_data", None) if _state else None
  1056. if _raw:
  1057. from backend.app.services.spoolman_tracking import build_ams_tray_lookup
  1058. available_trays = sorted(build_ams_tray_lookup(_raw).keys())
  1059. if slot_id <= len(available_trays):
  1060. global_tray_id = available_trays[slot_id - 1]
  1061. # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)
  1062. if global_tray_id is None:
  1063. global_tray_id = slot_id - 1
  1064. if global_tray_id >= 254:
  1065. # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
  1066. ams_id = 255
  1067. tray_id = global_tray_id - 254
  1068. elif global_tray_id >= 128:
  1069. ams_id = global_tray_id
  1070. tray_id = 0
  1071. else:
  1072. ams_id = global_tray_id // 4
  1073. tray_id = global_tray_id % 4
  1074. logger.info(
  1075. "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",
  1076. slot_id,
  1077. global_tray_id,
  1078. ams_id,
  1079. tray_id,
  1080. used_g,
  1081. tray_now_override,
  1082. )
  1083. key = (ams_id, tray_id)
  1084. if key in handled_trays:
  1085. continue
  1086. spool_id = await _resolve_spool_id_for_tray(
  1087. printer_id=printer_id,
  1088. ams_id=ams_id,
  1089. tray_id=tray_id,
  1090. db=db,
  1091. spool_assignments_snapshot=spool_assignments,
  1092. print_started_at=print_started_at,
  1093. )
  1094. if spool_id is None:
  1095. logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
  1096. continue
  1097. # Load spool
  1098. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1099. spool = spool_result.scalar_one_or_none()
  1100. if not spool:
  1101. continue
  1102. # Use per-layer grams if available, otherwise linear scale
  1103. if layer_grams and slot_id in layer_grams:
  1104. weight_grams = layer_grams[slot_id]
  1105. else:
  1106. weight_grams = used_g * scale
  1107. if weight_grams <= 0:
  1108. continue
  1109. # Update spool
  1110. spool.weight_used = (spool.weight_used or 0) + weight_grams
  1111. spool.last_used = datetime.now(timezone.utc)
  1112. percent = round(weight_grams / (spool.label_weight or 1000) * 100)
  1113. # Calculate cost for this usage
  1114. cost = None
  1115. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  1116. if cost_per_kg > 0:
  1117. cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
  1118. # Insert usage history record
  1119. history = SpoolUsageHistory(
  1120. spool_id=spool.id,
  1121. printer_id=printer_id,
  1122. print_name=print_name,
  1123. weight_used=round(weight_grams, 1),
  1124. percent_used=percent,
  1125. status=status,
  1126. cost=cost,
  1127. archive_id=archive_id,
  1128. )
  1129. db.add(history)
  1130. handled_trays.add(key)
  1131. results.append(
  1132. {
  1133. "spool_id": spool.id,
  1134. "weight_used": round(weight_grams, 1),
  1135. "percent_used": percent,
  1136. "ams_id": ams_id,
  1137. "tray_id": tray_id,
  1138. "material": spool.material,
  1139. "cost": cost,
  1140. "slot_id": slot_id,
  1141. "color": _spool_color_to_hex(spool.rgba),
  1142. }
  1143. )
  1144. # Determine mapping source for debug logging
  1145. if tray_now_override is not None:
  1146. map_src = ", tray_now"
  1147. elif mapping_source:
  1148. map_src = f", {mapping_source}_map"
  1149. else:
  1150. map_src = ""
  1151. logger.info(
  1152. "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)",
  1153. spool.id,
  1154. weight_grams,
  1155. " per-layer" if (layer_grams and slot_id in layer_grams) else (f" scaled {scale:.0%}" if scale < 1 else ""),
  1156. map_src,
  1157. printer_id,
  1158. ams_id,
  1159. tray_id,
  1160. status,
  1161. )
  1162. # --- Adopt the matched inventory spools' colours for the archive (#1494) ---
  1163. # The archive's filament_color was set from the slicer's 3MF at creation
  1164. # time; now that every used slot has been resolved to an inventory spool,
  1165. # the curated spool colour is authoritative. Committed by the caller's
  1166. # `if results: await db.commit()`.
  1167. if archive is not None:
  1168. spool_colors = _archive_colors_from_spools(filament_usage, results)
  1169. if spool_colors:
  1170. joined = ",".join(spool_colors)
  1171. if joined != archive.filament_color:
  1172. logger.info(
  1173. "[UsageTracker] 3MF: archive %s filament_color %r -> %r (from inventory spools)",
  1174. archive_id,
  1175. archive.filament_color,
  1176. joined,
  1177. )
  1178. archive.filament_color = joined
  1179. return results