usage_tracker.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  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. """
  5. import logging
  6. from dataclasses import dataclass, field
  7. from datetime import datetime, timezone
  8. from sqlalchemy import select
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.models.spool import Spool
  11. from backend.app.models.spool_assignment import SpoolAssignment
  12. from backend.app.models.spool_usage_history import SpoolUsageHistory
  13. logger = logging.getLogger(__name__)
  14. @dataclass
  15. class PrintSession:
  16. printer_id: int
  17. print_name: str
  18. started_at: datetime
  19. tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
  20. # Module-level storage, keyed by printer_id
  21. _active_sessions: dict[int, PrintSession] = {}
  22. async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
  23. """Capture AMS tray remain% at print start."""
  24. state = printer_manager.get_status(printer_id)
  25. if not state or not state.raw_data:
  26. logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
  27. return
  28. ams_data = state.raw_data.get("ams", {}).get("ams", [])
  29. if not ams_data:
  30. logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
  31. return
  32. tray_remain_start: dict[tuple[int, int], int] = {}
  33. for ams_unit in ams_data:
  34. ams_id = int(ams_unit.get("id", 0))
  35. for tray in ams_unit.get("tray", []):
  36. tray_id = int(tray.get("id", 0))
  37. remain = tray.get("remain", -1)
  38. if isinstance(remain, int) and 0 <= remain <= 100:
  39. tray_remain_start[(ams_id, tray_id)] = remain
  40. if not tray_remain_start:
  41. logger.debug("[UsageTracker] No valid remain%% data for printer %d", printer_id)
  42. return
  43. print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
  44. session = PrintSession(
  45. printer_id=printer_id,
  46. print_name=print_name,
  47. started_at=datetime.now(timezone.utc),
  48. tray_remain_start=tray_remain_start,
  49. )
  50. _active_sessions[printer_id] = session
  51. logger.info(
  52. "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
  53. printer_id,
  54. len(tray_remain_start),
  55. {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
  56. )
  57. async def on_print_complete(
  58. printer_id: int,
  59. data: dict,
  60. printer_manager,
  61. db: AsyncSession,
  62. ) -> list[dict]:
  63. """Compute consumption deltas and update spool weight_used/last_used.
  64. Returns a list of dicts describing what was logged (for WebSocket broadcast).
  65. """
  66. session = _active_sessions.pop(printer_id, None)
  67. if not session:
  68. logger.debug("[UsageTracker] No active session for printer %d, skipping", printer_id)
  69. return []
  70. # Read current remain%
  71. state = printer_manager.get_status(printer_id)
  72. if not state or not state.raw_data:
  73. logger.warning("[UsageTracker] No state at print complete for printer %d", printer_id)
  74. return []
  75. ams_data = state.raw_data.get("ams", {}).get("ams", [])
  76. status = data.get("status", "completed")
  77. results = []
  78. for ams_unit in ams_data:
  79. ams_id = int(ams_unit.get("id", 0))
  80. for tray in ams_unit.get("tray", []):
  81. tray_id = int(tray.get("id", 0))
  82. key = (ams_id, tray_id)
  83. if key not in session.tray_remain_start:
  84. continue
  85. current_remain = tray.get("remain", -1)
  86. if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
  87. continue
  88. start_remain = session.tray_remain_start[key]
  89. delta_pct = start_remain - current_remain
  90. if delta_pct <= 0:
  91. continue # No consumption or tray was refilled
  92. # Look up SpoolAssignment for this slot
  93. result = await db.execute(
  94. select(SpoolAssignment).where(
  95. SpoolAssignment.printer_id == printer_id,
  96. SpoolAssignment.ams_id == ams_id,
  97. SpoolAssignment.tray_id == tray_id,
  98. )
  99. )
  100. assignment = result.scalar_one_or_none()
  101. if not assignment:
  102. continue
  103. # Load spool
  104. spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
  105. spool = spool_result.scalar_one_or_none()
  106. if not spool:
  107. continue
  108. # Compute weight consumed
  109. weight_grams = (delta_pct / 100.0) * spool.label_weight
  110. # Update spool
  111. spool.weight_used = (spool.weight_used or 0) + weight_grams
  112. spool.last_used = datetime.now(timezone.utc)
  113. # Insert usage history record
  114. history = SpoolUsageHistory(
  115. spool_id=spool.id,
  116. printer_id=printer_id,
  117. print_name=session.print_name,
  118. weight_used=round(weight_grams, 1),
  119. percent_used=delta_pct,
  120. status=status,
  121. )
  122. db.add(history)
  123. results.append(
  124. {
  125. "spool_id": spool.id,
  126. "weight_used": round(weight_grams, 1),
  127. "percent_used": delta_pct,
  128. "ams_id": ams_id,
  129. "tray_id": tray_id,
  130. }
  131. )
  132. logger.info(
  133. "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)",
  134. spool.id,
  135. weight_grams,
  136. delta_pct,
  137. printer_id,
  138. ams_id,
  139. tray_id,
  140. status,
  141. )
  142. if results:
  143. await db.commit()
  144. return results