usage_tracker.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778
  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 _match_slots_by_color(
  50. filament_usage: list[dict],
  51. ams_raw: dict | list | None,
  52. ) -> list[int] | None:
  53. """Match 3MF filament slots to AMS trays by color.
  54. Fallback mapping for printers that don't provide the MQTT mapping field
  55. or request topic subscription (e.g. A1, A1 Mini, P1S, P2S).
  56. Compares the 3MF slicer filament color (per slot) against each AMS tray's
  57. color to find a unique match. Only returns a mapping if every used slot
  58. matches exactly one tray (no ambiguity).
  59. Args:
  60. filament_usage: List of 3MF slot dicts with 'slot_id', 'color', 'type'
  61. ams_raw: raw_data["ams"] dict or list from printer state
  62. Returns:
  63. List of global tray IDs indexed by slicer slot (0-based), or None.
  64. """
  65. if not filament_usage or not ams_raw:
  66. return None
  67. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  68. if not ams_data:
  69. return None
  70. # Build map of normalized color → list of global tray IDs
  71. color_to_trays: dict[str, list[int]] = {}
  72. for ams_unit in ams_data:
  73. ams_id = int(ams_unit.get("id", 0))
  74. for tray in ams_unit.get("tray", []):
  75. tray_id = int(tray.get("id", 0))
  76. tray_color = tray.get("tray_color", "")
  77. tray_type = tray.get("tray_type", "")
  78. if not tray_color or not tray_type:
  79. continue
  80. # Normalize AMS color: strip alpha (last 2 chars), lowercase
  81. norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
  82. if ams_id >= 128:
  83. global_id = ams_id # AMS-HT
  84. else:
  85. global_id = ams_id * 4 + tray_id
  86. color_to_trays.setdefault(norm, []).append(global_id)
  87. if not color_to_trays:
  88. return None
  89. # Find max slot_id to size the result array
  90. max_slot = max(u.get("slot_id", 0) for u in filament_usage)
  91. if max_slot <= 0:
  92. return None
  93. result = [-1] * max_slot
  94. used_trays: set[int] = set()
  95. for usage in filament_usage:
  96. slot_id = usage.get("slot_id", 0)
  97. if slot_id <= 0:
  98. continue
  99. slot_color = usage.get("color", "").lstrip("#").lower()
  100. if len(slot_color) < 6:
  101. return None # Can't match without a valid color
  102. slot_color = slot_color[:6] # Strip alpha if present
  103. candidates = color_to_trays.get(slot_color, [])
  104. # Filter out trays already claimed by another slot
  105. available = [t for t in candidates if t not in used_trays]
  106. if len(available) != 1:
  107. # Ambiguous (multiple trays with same color) or no match
  108. return None
  109. result[slot_id - 1] = available[0]
  110. used_trays.add(available[0])
  111. # Only return if at least one valid mapping exists
  112. if all(v < 0 for v in result):
  113. return None
  114. logger.info("[UsageTracker] Color-matched slot_to_tray: %s", result)
  115. return result
  116. @dataclass
  117. class PrintSession:
  118. printer_id: int
  119. print_name: str
  120. started_at: datetime
  121. tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
  122. # tray_now at print start (correct value, unlike at completion where it's 255)
  123. tray_now_at_start: int = -1
  124. # Snapshot of spool assignments at print start: {(ams_id, tray_id): spool_id}
  125. # Prevents usage loss when on_ams_change unlinks a spool mid-print
  126. spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)
  127. # Module-level storage, keyed by printer_id
  128. _active_sessions: dict[int, PrintSession] = {}
  129. async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
  130. """Capture AMS tray remain% and spool assignments at print start."""
  131. state = printer_manager.get_status(printer_id)
  132. if not state or not state.raw_data:
  133. logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
  134. return
  135. ams_raw = state.raw_data.get("ams", [])
  136. ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  137. if not ams_data:
  138. logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
  139. return
  140. tray_remain_start: dict[tuple[int, int], int] = {}
  141. for ams_unit in ams_data:
  142. ams_id = int(ams_unit.get("id", 0))
  143. for tray in ams_unit.get("tray", []):
  144. tray_id = int(tray.get("id", 0))
  145. remain = tray.get("remain", -1)
  146. if isinstance(remain, int) and 0 <= remain <= 100:
  147. tray_remain_start[(ams_id, tray_id)] = remain
  148. print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
  149. # Capture tray_now at print start (reliable, unlike at completion where it's 255)
  150. tray_now_at_start = state.tray_now if state else -1
  151. # --- Diagnostic logging: dump mapping-related MQTT fields at print start ---
  152. # This helps us understand what each printer model reports for slot-to-tray mapping.
  153. mapping_field = state.raw_data.get("mapping")
  154. logger.info(
  155. "[UsageTracker] PRINT START printer %d: mapping=%s, tray_now=%d, last_loaded_tray=%s",
  156. printer_id,
  157. mapping_field,
  158. tray_now_at_start,
  159. getattr(state, "last_loaded_tray", "N/A"),
  160. )
  161. # Log all raw_data keys containing "map" or "ams" for discovery
  162. map_keys = {k: state.raw_data[k] for k in state.raw_data if "map" in k.lower()}
  163. if map_keys:
  164. logger.info("[UsageTracker] PRINT START printer %d: mapping-related keys: %s", printer_id, map_keys)
  165. # Log per-tray summary: tray_now, tray_tar, tray_type, tray_color for each slot
  166. for ams_unit in ams_data:
  167. ams_id = int(ams_unit.get("id", 0))
  168. tray_summary = []
  169. for tray in ams_unit.get("tray", []):
  170. tray_summary.append(
  171. f"T{tray.get('id', '?')}(type={tray.get('tray_type', '')}, "
  172. f"color={tray.get('tray_color', '')}, "
  173. f"now={ams_raw.get('tray_now', '?') if isinstance(ams_raw, dict) else '?'}, "
  174. f"tar={ams_raw.get('tray_tar', '?') if isinstance(ams_raw, dict) else '?'})"
  175. )
  176. logger.info("[UsageTracker] PRINT START printer %d AMS %d: %s", printer_id, ams_id, ", ".join(tray_summary))
  177. # Snapshot spool assignments so usage isn't lost if on_ams_change unlinks mid-print
  178. spool_assignments: dict[tuple[int, int], int] = {}
  179. if db:
  180. assign_result = await db.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer_id))
  181. for assignment in assign_result.scalars().all():
  182. spool_assignments[(assignment.ams_id, assignment.tray_id)] = assignment.spool_id
  183. if spool_assignments:
  184. logger.info(
  185. "[UsageTracker] Snapshotted %d spool assignments for printer %d: %s",
  186. len(spool_assignments),
  187. printer_id,
  188. {f"{k[0]}-{k[1]}": v for k, v in spool_assignments.items()},
  189. )
  190. # Always create session (even without valid remain data) so print_name
  191. # is available at completion for 3MF-based tracking
  192. session = PrintSession(
  193. printer_id=printer_id,
  194. print_name=print_name,
  195. started_at=datetime.now(timezone.utc),
  196. tray_remain_start=tray_remain_start,
  197. tray_now_at_start=tray_now_at_start,
  198. spool_assignments=spool_assignments,
  199. )
  200. _active_sessions[printer_id] = session
  201. if tray_remain_start:
  202. logger.info(
  203. "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
  204. printer_id,
  205. len(tray_remain_start),
  206. {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
  207. )
  208. else:
  209. logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
  210. async def on_print_complete(
  211. printer_id: int,
  212. data: dict,
  213. printer_manager,
  214. db: AsyncSession,
  215. archive_id: int | None = None,
  216. ams_mapping: list[int] | None = None,
  217. ) -> list[dict]:
  218. """Compute consumption deltas and update spool weight_used/last_used.
  219. Uses two tracking strategies in priority order:
  220. 1. 3MF per-filament estimates (primary) — precise slicer data for all spools
  221. 2. AMS remain% delta (fallback) — only for trays not already handled by 3MF
  222. Returns a list of dicts describing what was logged (for WebSocket broadcast).
  223. """
  224. from sqlalchemy import select
  225. from backend.app.api.routes.settings import get_setting
  226. from backend.app.models.spool_usage_history import SpoolUsageHistory
  227. session = _active_sessions.pop(printer_id, None)
  228. status = data.get("status", "completed")
  229. results = []
  230. handled_trays: set[tuple[int, int]] = set()
  231. # Fetch default filament cost from settings for fallback
  232. default_cost_str = await get_setting(db, "default_filament_cost")
  233. default_filament_cost = float(default_cost_str) if default_cost_str else 0.0
  234. logger.info(
  235. "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s",
  236. printer_id,
  237. archive_id,
  238. "yes" if session else "no",
  239. ams_mapping,
  240. )
  241. # --- Diagnostic logging: dump mapping-related MQTT fields at print completion ---
  242. state = printer_manager.get_status(printer_id)
  243. if state and state.raw_data:
  244. logger.info(
  245. "[UsageTracker] PRINT COMPLETE printer %d: mapping=%s, tray_now=%s, last_loaded_tray=%s",
  246. printer_id,
  247. state.raw_data.get("mapping"),
  248. state.tray_now,
  249. getattr(state, "last_loaded_tray", "N/A"),
  250. )
  251. # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
  252. if archive_id:
  253. print_name = (
  254. (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
  255. )
  256. threemf_results = await _track_from_3mf(
  257. printer_id,
  258. archive_id,
  259. status,
  260. print_name,
  261. handled_trays,
  262. printer_manager,
  263. db,
  264. ams_mapping=ams_mapping,
  265. tray_now_at_start=session.tray_now_at_start if session else -1,
  266. last_progress=data.get("last_progress", 0.0),
  267. last_layer_num=data.get("last_layer_num", 0),
  268. default_filament_cost=default_filament_cost,
  269. spool_assignments=session.spool_assignments if session else None,
  270. )
  271. results.extend(threemf_results)
  272. # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---
  273. if session and session.tray_remain_start:
  274. state = printer_manager.get_status(printer_id)
  275. if state and state.raw_data:
  276. ams_raw = state.raw_data.get("ams", [])
  277. ams_data = (
  278. ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  279. )
  280. for ams_unit in ams_data:
  281. ams_id = int(ams_unit.get("id", 0))
  282. for tray in ams_unit.get("tray", []):
  283. tray_id = int(tray.get("id", 0))
  284. key = (ams_id, tray_id)
  285. if key in handled_trays:
  286. continue # Already tracked via 3MF
  287. if key not in session.tray_remain_start:
  288. continue
  289. current_remain = tray.get("remain", -1)
  290. if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
  291. continue
  292. start_remain = session.tray_remain_start[key]
  293. delta_pct = start_remain - current_remain
  294. if delta_pct <= 0:
  295. continue # No consumption or tray was refilled
  296. # Look up spool: prefer snapshot (survives mid-print unlink), fall back to live query
  297. spool_id = session.spool_assignments.get(key) if session.spool_assignments else None
  298. if spool_id is None:
  299. result = await db.execute(
  300. select(SpoolAssignment).where(
  301. SpoolAssignment.printer_id == printer_id,
  302. SpoolAssignment.ams_id == ams_id,
  303. SpoolAssignment.tray_id == tray_id,
  304. )
  305. )
  306. assignment = result.scalar_one_or_none()
  307. if not assignment:
  308. continue
  309. spool_id = assignment.spool_id
  310. # Load spool
  311. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  312. spool = spool_result.scalar_one_or_none()
  313. if not spool:
  314. continue
  315. # Compute weight consumed
  316. weight_grams = (delta_pct / 100.0) * spool.label_weight
  317. # Update spool
  318. spool.weight_used = (spool.weight_used or 0) + weight_grams
  319. spool.last_used = datetime.now(timezone.utc)
  320. # Calculate cost for this usage
  321. cost = None
  322. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  323. if cost_per_kg > 0:
  324. cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
  325. # Insert usage history record
  326. history = SpoolUsageHistory(
  327. spool_id=spool.id,
  328. printer_id=printer_id,
  329. print_name=session.print_name,
  330. weight_used=round(weight_grams, 1),
  331. percent_used=delta_pct,
  332. status=status,
  333. cost=cost,
  334. archive_id=archive_id,
  335. )
  336. db.add(history)
  337. handled_trays.add(key)
  338. results.append(
  339. {
  340. "spool_id": spool.id,
  341. "weight_used": round(weight_grams, 1),
  342. "percent_used": delta_pct,
  343. "ams_id": ams_id,
  344. "tray_id": tray_id,
  345. "material": spool.material,
  346. "cost": cost,
  347. }
  348. )
  349. logger.info(
  350. "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (AMS fallback, %s)",
  351. spool.id,
  352. weight_grams,
  353. delta_pct,
  354. printer_id,
  355. ams_id,
  356. tray_id,
  357. status,
  358. )
  359. if results:
  360. await db.commit()
  361. # --- Update PrintArchive.cost to sum all SpoolUsageHistory costs for this archive ---
  362. if archive_id:
  363. from sqlalchemy import func, select
  364. from backend.app.models.archive import PrintArchive
  365. # First try: sum by archive_id
  366. cost_result = await db.execute(
  367. select(func.coalesce(func.sum(SpoolUsageHistory.cost), 0)).where(SpoolUsageHistory.archive_id == archive_id)
  368. )
  369. total_cost = cost_result.scalar() or 0
  370. # Fallback: if no cost found, sum by print_name and printer_id (legacy)
  371. archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  372. archive = archive_result.scalar_one_or_none()
  373. if archive and total_cost == 0 and archive.print_name and archive.printer_id:
  374. legacy_cost_result = await db.execute(
  375. select(func.coalesce(func.sum(SpoolUsageHistory.cost), 0)).where(
  376. SpoolUsageHistory.archive_id is None,
  377. SpoolUsageHistory.print_name == archive.print_name,
  378. SpoolUsageHistory.printer_id == archive.printer_id,
  379. )
  380. )
  381. total_cost = legacy_cost_result.scalar() or 0
  382. if archive:
  383. archive.cost = total_cost
  384. await db.commit()
  385. return results
  386. async def _track_from_3mf(
  387. printer_id: int,
  388. archive_id: int,
  389. status: str,
  390. print_name: str,
  391. handled_trays: set[tuple[int, int]],
  392. printer_manager,
  393. db: AsyncSession,
  394. ams_mapping: list[int] | None = None,
  395. tray_now_at_start: int = -1,
  396. last_progress: float = 0.0,
  397. last_layer_num: int = 0,
  398. default_filament_cost: float = 0.0,
  399. spool_assignments: dict[tuple[int, int], int] | None = None,
  400. ) -> list[dict]:
  401. """Track usage from 3MF per-filament slicer data (primary path).
  402. Uses slicer-estimated filament weight for all spools (BL and non-BL).
  403. For partial prints (failed/aborted), tries per-layer gcode data first,
  404. then falls back to linear scaling by progress.
  405. Slot-to-tray mapping priority:
  406. 1. Stored ams_mapping from print command (reprints/direct prints)
  407. 2. MQTT mapping field from printer state (universal, all print sources)
  408. 3. Queue item ams_mapping (for queue-initiated prints)
  409. 4. tray_now from printer state (for single-filament non-queue prints)
  410. 5. Default mapping: slot_id - 1 = global_tray_id (last resort)
  411. """
  412. from backend.app.core.config import settings as app_settings
  413. from backend.app.models.archive import PrintArchive
  414. from backend.app.models.print_queue import PrintQueueItem
  415. from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
  416. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  417. archive = result.scalar_one_or_none()
  418. if not archive or not archive.file_path:
  419. logger.info("[UsageTracker] 3MF: archive %s has no file_path, skipping", archive_id)
  420. return []
  421. file_path = app_settings.base_dir / archive.file_path
  422. if not file_path.exists():
  423. logger.info("[UsageTracker] 3MF: file not found: %s", file_path)
  424. return []
  425. filament_usage = extract_filament_usage_from_3mf(file_path)
  426. if not filament_usage:
  427. logger.info("[UsageTracker] 3MF: no filament usage data in %s", file_path)
  428. return []
  429. logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
  430. # --- Resolve slot-to-tray mapping ---
  431. mapping_source = None
  432. # 1. Use stored ams_mapping from the print command (reprints/direct prints)
  433. slot_to_tray = ams_mapping
  434. if slot_to_tray:
  435. mapping_source = "print_cmd"
  436. # 2. Try MQTT mapping field from printer state (universal, all print sources)
  437. if not slot_to_tray:
  438. state = printer_manager.get_status(printer_id)
  439. raw_data = getattr(state, "raw_data", None) if state else None
  440. if raw_data:
  441. mqtt_mapping = raw_data.get("mapping")
  442. decoded = _decode_mqtt_mapping(mqtt_mapping)
  443. if decoded:
  444. slot_to_tray = decoded
  445. mapping_source = "mqtt"
  446. # 3. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
  447. if not slot_to_tray:
  448. queue_result = await db.execute(
  449. select(PrintQueueItem)
  450. .where(PrintQueueItem.archive_id == archive_id)
  451. .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
  452. )
  453. queue_item = queue_result.scalar_one_or_none()
  454. if queue_item and queue_item.ams_mapping:
  455. try:
  456. slot_to_tray = json.loads(queue_item.ams_mapping)
  457. mapping_source = "queue"
  458. except (json.JSONDecodeError, TypeError):
  459. pass
  460. # 4. Color-match 3MF filament slots to AMS trays (for printers without mapping field)
  461. if not slot_to_tray:
  462. state = printer_manager.get_status(printer_id)
  463. raw_data = getattr(state, "raw_data", None) if state else None
  464. if raw_data:
  465. matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
  466. if matched:
  467. slot_to_tray = matched
  468. mapping_source = "color_match"
  469. logger.info(
  470. "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
  471. slot_to_tray,
  472. mapping_source or "none",
  473. )
  474. # 5. For single-filament non-queue prints, use tray_now from printer state
  475. # Priority: tray_now_at_start > current tray_now > last_loaded_tray > vt_tray check
  476. nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
  477. tray_now_override: int | None = None
  478. if not slot_to_tray and len(nonzero_slots) == 1:
  479. state = printer_manager.get_status(printer_id)
  480. # Try tray_now_at_start first (captured at print start)
  481. if 0 <= tray_now_at_start <= 254:
  482. tray_now_override = tray_now_at_start
  483. logger.info("[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)", tray_now_at_start)
  484. elif state and 0 <= state.tray_now <= 254:
  485. # Current state is valid (printer didn't retract yet)
  486. tray_now_override = state.tray_now
  487. logger.info("[UsageTracker] 3MF: using current tray_now=%d", state.tray_now)
  488. elif state and 0 <= state.last_loaded_tray <= 253:
  489. # Last valid tray before retract (H2D retracts before completion callback)
  490. tray_now_override = state.last_loaded_tray
  491. logger.info("[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)", state.last_loaded_tray)
  492. elif state and state.tray_now == 255:
  493. # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
  494. vt_tray = state.raw_data.get("vt_tray") or []
  495. if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
  496. tray_now_override = state.tray_now
  497. logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
  498. if tray_now_override is None:
  499. logger.info(
  500. "[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",
  501. tray_now_at_start,
  502. state.tray_now if state else "N/A",
  503. state.last_loaded_tray if state else "N/A",
  504. )
  505. # Scale factor for partial prints (failed/aborted)
  506. if status == "completed":
  507. scale = 1.0
  508. else:
  509. state = printer_manager.get_status(printer_id)
  510. progress = state.progress if state else 0
  511. # Firmware resets progress to 0 on cancel — use last valid progress captured during print
  512. if progress <= 0 and last_progress > 0:
  513. progress = last_progress
  514. logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
  515. scale = max(0.0, min(progress / 100.0, 1.0))
  516. # Per-layer gcode accuracy for partial prints
  517. layer_grams: dict[int, float] | None = None
  518. if status != "completed":
  519. state = printer_manager.get_status(printer_id)
  520. current_layer = state.layer_num if state else 0
  521. # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
  522. if current_layer <= 0 and last_layer_num > 0:
  523. current_layer = last_layer_num
  524. logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
  525. if current_layer > 0:
  526. try:
  527. from backend.app.utils.threemf_tools import (
  528. extract_filament_properties_from_3mf,
  529. extract_layer_filament_usage_from_3mf,
  530. get_cumulative_usage_at_layer,
  531. mm_to_grams,
  532. )
  533. layer_usage = extract_layer_filament_usage_from_3mf(file_path)
  534. if layer_usage:
  535. cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
  536. filament_props = extract_filament_properties_from_3mf(file_path)
  537. layer_grams = {}
  538. for filament_id, mm_used in cumulative_mm.items():
  539. slot_id = filament_id + 1 # 0-based to 1-based
  540. props = filament_props.get(slot_id, {})
  541. density = props.get("density", 1.24)
  542. diameter = props.get("diameter", 1.75)
  543. layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
  544. except Exception:
  545. pass # Fall back to linear scaling
  546. results = []
  547. for usage in filament_usage:
  548. slot_id = usage.get("slot_id", 0)
  549. used_g = usage.get("used_g", 0)
  550. if used_g <= 0:
  551. continue
  552. # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping
  553. if tray_now_override is not None:
  554. # Single-filament non-queue print: use actual tray from printer state
  555. global_tray_id = tray_now_override
  556. else:
  557. # Queue mapping or default: slot_id - 1, overridden by ams_mapping
  558. global_tray_id = slot_id - 1
  559. if slot_to_tray and slot_id <= len(slot_to_tray):
  560. mapped = slot_to_tray[slot_id - 1]
  561. if isinstance(mapped, int) and mapped >= 0:
  562. global_tray_id = mapped
  563. if global_tray_id >= 254:
  564. # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
  565. ams_id = 255
  566. tray_id = global_tray_id - 254
  567. elif global_tray_id >= 128:
  568. ams_id = global_tray_id
  569. tray_id = 0
  570. else:
  571. ams_id = global_tray_id // 4
  572. tray_id = global_tray_id % 4
  573. logger.info(
  574. "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",
  575. slot_id,
  576. global_tray_id,
  577. ams_id,
  578. tray_id,
  579. used_g,
  580. tray_now_override,
  581. )
  582. key = (ams_id, tray_id)
  583. if key in handled_trays:
  584. continue
  585. # Find spool: prefer snapshot (survives mid-print unlink), fall back to live query
  586. spool_id = spool_assignments.get(key) if spool_assignments else None
  587. if spool_id is None:
  588. assign_result = await db.execute(
  589. select(SpoolAssignment).where(
  590. SpoolAssignment.printer_id == printer_id,
  591. SpoolAssignment.ams_id == ams_id,
  592. SpoolAssignment.tray_id == tray_id,
  593. )
  594. )
  595. assignment = assign_result.scalar_one_or_none()
  596. if not assignment:
  597. logger.info(
  598. "[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id
  599. )
  600. continue
  601. spool_id = assignment.spool_id
  602. # Load spool
  603. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  604. spool = spool_result.scalar_one_or_none()
  605. if not spool:
  606. continue
  607. # Use per-layer grams if available, otherwise linear scale
  608. if layer_grams and slot_id in layer_grams:
  609. weight_grams = layer_grams[slot_id]
  610. else:
  611. weight_grams = used_g * scale
  612. if weight_grams <= 0:
  613. continue
  614. # Update spool
  615. spool.weight_used = (spool.weight_used or 0) + weight_grams
  616. spool.last_used = datetime.now(timezone.utc)
  617. percent = round(weight_grams / (spool.label_weight or 1000) * 100)
  618. # Calculate cost for this usage
  619. cost = None
  620. cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
  621. if cost_per_kg > 0:
  622. cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
  623. # Insert usage history record
  624. history = SpoolUsageHistory(
  625. spool_id=spool.id,
  626. printer_id=printer_id,
  627. print_name=print_name,
  628. weight_used=round(weight_grams, 1),
  629. percent_used=percent,
  630. status=status,
  631. cost=cost,
  632. archive_id=archive_id,
  633. )
  634. db.add(history)
  635. handled_trays.add(key)
  636. results.append(
  637. {
  638. "spool_id": spool.id,
  639. "weight_used": round(weight_grams, 1),
  640. "percent_used": percent,
  641. "ams_id": ams_id,
  642. "tray_id": tray_id,
  643. "material": spool.material,
  644. "cost": cost,
  645. }
  646. )
  647. # Determine mapping source for debug logging
  648. if tray_now_override is not None:
  649. map_src = ", tray_now"
  650. elif mapping_source:
  651. map_src = f", {mapping_source}_map"
  652. else:
  653. map_src = ""
  654. logger.info(
  655. "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)",
  656. spool.id,
  657. weight_grams,
  658. " per-layer" if (layer_grams and slot_id in layer_grams) else (f" scaled {scale:.0%}" if scale < 1 else ""),
  659. map_src,
  660. printer_id,
  661. ams_id,
  662. tray_id,
  663. status,
  664. )
  665. return results