Fix spool weight tracking for fallback archives, VT trays, and notifications (#839)

Usage tracking failed silently when FTP download failed (fallback archive
  with no 3MF), when printing from external spool holder (VT tray not
  iterated by AMS fallback), and notifications showed "Unknown" for time
  and filament. Now resolves 3MF from library/previous archives, tracks
  VT tray remain% deltas, enriches notifications with usage tracker
  results, and captures print time from MQTT for fallback archives.
This commit is contained in:
maziggy
2026-04-05 09:59:05 +02:00
parent 1645b51dad
commit aa9b60a265
3 changed files with 255 additions and 84 deletions
+222 -84
View File
@@ -237,11 +237,10 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
ams_raw = state.raw_data.get("ams", [])
ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
if not ams_data:
logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
return
tray_remain_start: dict[tuple[int, int], int] = {}
skipped_invalid: list[str] = []
for ams_unit in ams_data:
ams_id = int(ams_unit.get("id", 0))
for tray in ams_unit.get("tray", []):
@@ -249,6 +248,35 @@ async def on_print_start(printer_id: int, data: dict, printer_manager, db: Async
remain = tray.get("remain", -1)
if isinstance(remain, int) and 0 <= remain <= 100:
tray_remain_start[(ams_id, tray_id)] = remain
else:
skipped_invalid.append(f"AMS{ams_id}-T{tray_id}(remain={remain})")
# Also capture VT (external) tray remain% — these are separate from AMS units
vt_tray_raw = state.raw_data.get("vt_tray") or []
if isinstance(vt_tray_raw, dict):
vt_tray_raw = [vt_tray_raw]
for vt in vt_tray_raw:
if not isinstance(vt, dict):
continue
vt_id = int(vt.get("id", 254))
# VT tray id 254 → (ams_id=255, tray_id=0), id 255 → (ams_id=255, tray_id=1)
vt_tray_id = vt_id - 254
remain = vt.get("remain", -1)
if isinstance(remain, int) and 0 <= remain <= 100:
tray_remain_start[(255, vt_tray_id)] = remain
else:
skipped_invalid.append(f"VT{vt_id}(remain={remain})")
if skipped_invalid:
logger.info(
"[UsageTracker] Skipped trays with invalid remain%% for printer %d: %s",
printer_id,
", ".join(skipped_invalid),
)
if not ams_data and not vt_tray_raw:
logger.debug("[UsageTracker] No AMS or VT tray data for printer %d, skipping", printer_id)
return
print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
@@ -400,94 +428,123 @@ async def on_print_complete(
ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
)
# Collect all trays to check: AMS trays + VT (external) trays
# Each entry: (ams_id_for_assignment, tray_id_for_assignment, current_remain, label)
trays_to_check: list[tuple[int, int, int, str]] = []
for ams_unit in ams_data:
ams_id = int(ams_unit.get("id", 0))
for tray in ams_unit.get("tray", []):
tray_id = int(tray.get("id", 0))
key = (ams_id, tray_id)
remain = tray.get("remain", -1)
trays_to_check.append((ams_id, tray_id, remain, f"AMS{ams_id}-T{tray_id}"))
if key in handled_trays:
continue # Already tracked via 3MF
# VT (external) trays — same remain% delta logic
vt_tray_raw = state.raw_data.get("vt_tray") or []
if isinstance(vt_tray_raw, dict):
vt_tray_raw = [vt_tray_raw]
for vt in vt_tray_raw:
if not isinstance(vt, dict):
continue
vt_id = int(vt.get("id", 254))
vt_tray_id = vt_id - 254 # 254→0, 255→1
remain = vt.get("remain", -1)
trays_to_check.append((255, vt_tray_id, remain, f"VT{vt_id}"))
if key not in session.tray_remain_start:
continue
for assign_ams_id, assign_tray_id, current_remain, tray_label in trays_to_check:
key = (assign_ams_id, assign_tray_id)
current_remain = tray.get("remain", -1)
if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
continue
if key in handled_trays:
continue # Already tracked via 3MF
start_remain = session.tray_remain_start[key]
delta_pct = start_remain - current_remain
if delta_pct <= 0:
continue # No consumption or tray was refilled
spool_id = await _resolve_spool_id_for_tray(
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
db=db,
spool_assignments_snapshot=session.spool_assignments,
print_started_at=session.started_at,
)
if spool_id is None:
continue
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
# Compute weight consumed
weight_grams = (delta_pct / 100.0) * spool.label_weight
# Update spool
spool.weight_used = (spool.weight_used or 0) + weight_grams
spool.last_used = datetime.now(timezone.utc)
# Calculate cost for this usage
cost = None
cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
if cost_per_kg > 0:
cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
# Insert usage history record
history = SpoolUsageHistory(
spool_id=spool.id,
printer_id=printer_id,
print_name=session.print_name,
weight_used=round(weight_grams, 1),
percent_used=delta_pct,
status=status,
cost=cost,
archive_id=archive_id,
)
db.add(history)
handled_trays.add(key)
results.append(
{
"spool_id": spool.id,
"weight_used": round(weight_grams, 1),
"percent_used": delta_pct,
"ams_id": ams_id,
"tray_id": tray_id,
"material": spool.material,
"cost": cost,
}
)
if key not in session.tray_remain_start:
continue
if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
logger.info(
"[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (AMS fallback, %s)",
spool.id,
weight_grams,
delta_pct,
"[UsageTracker] %s: invalid remain%% at completion (%s), skipping fallback for printer %d",
tray_label,
current_remain,
printer_id,
ams_id,
tray_id,
status,
)
continue
start_remain = session.tray_remain_start[key]
delta_pct = start_remain - current_remain
if delta_pct <= 0:
continue # No consumption or tray was refilled
spool_id = await _resolve_spool_id_for_tray(
printer_id=printer_id,
ams_id=assign_ams_id,
tray_id=assign_tray_id,
db=db,
spool_assignments_snapshot=session.spool_assignments,
print_started_at=session.started_at,
)
if spool_id is None:
logger.info(
"[UsageTracker] %s: no spool assigned, skipping fallback for printer %d",
tray_label,
printer_id,
)
continue
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
# Compute weight consumed
weight_grams = (delta_pct / 100.0) * spool.label_weight
# Update spool
spool.weight_used = (spool.weight_used or 0) + weight_grams
spool.last_used = datetime.now(timezone.utc)
# Calculate cost for this usage
cost = None
cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost
if cost_per_kg > 0:
cost = round((weight_grams / 1000.0) * cost_per_kg, 2)
# Insert usage history record
history = SpoolUsageHistory(
spool_id=spool.id,
printer_id=printer_id,
print_name=session.print_name,
weight_used=round(weight_grams, 1),
percent_used=delta_pct,
status=status,
cost=cost,
archive_id=archive_id,
)
db.add(history)
handled_trays.add(key)
results.append(
{
"spool_id": spool.id,
"weight_used": round(weight_grams, 1),
"percent_used": delta_pct,
"ams_id": assign_ams_id,
"tray_id": assign_tray_id,
"material": spool.material,
"cost": cost,
}
)
logger.info(
"[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d %s (AMS fallback, %s)",
spool.id,
weight_grams,
delta_pct,
printer_id,
tray_label,
status,
)
if results:
await db.commit()
@@ -510,6 +567,74 @@ async def on_print_complete(
return results
async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
"""Try to find a 3MF file from library or a previous archive when the current archive has none.
This handles fallback archives (FTP download failed) where the 3MF may already exist
locally from a library upload or a previous successful print of the same file.
"""
from pathlib import Path
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile
# Derive search name from archive filename (e.g. "benchy.3mf" or "benchy.gcode.3mf")
search_name = archive.filename or archive.print_name
if not search_name:
return None
# Normalize: strip path parts, get base name
search_name = search_name.split("/")[-1]
search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
if not search_base:
return None
# 1. Try library files matching the name (match base name at file boundary)
try:
lib_result = await db.execute(
select(LibraryFile)
.where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
.where(LibraryFile.file_path.ilike("%.3mf"))
.order_by(LibraryFile.created_at.desc())
.limit(3)
)
for lib_file in lib_result.scalars().all():
lib_path = Path(lib_file.file_path)
candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
if candidate.exists() and candidate.suffix == ".3mf":
logger.info("[UsageTracker] 3MF fallback: found library file %s for archive %s", candidate, archive.id)
return candidate
except Exception as e:
logger.debug("[UsageTracker] 3MF fallback: library lookup failed: %s", e)
# 2. Try previous archives with the same filename that have a valid file_path
try:
prev_result = await db.execute(
select(PrintArchive)
.where(PrintArchive.id != archive.id)
.where(PrintArchive.printer_id == archive.printer_id)
.where(PrintArchive.file_path != "")
.where(PrintArchive.file_path.isnot(None))
.where(
PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
)
.order_by(PrintArchive.created_at.desc())
.limit(3)
)
for prev_archive in prev_result.scalars().all():
candidate = base_dir / prev_archive.file_path
if candidate.exists() and candidate.suffix == ".3mf":
logger.info(
"[UsageTracker] 3MF fallback: found previous archive %s file for archive %s",
prev_archive.id,
archive.id,
)
return candidate
except Exception as e:
logger.debug("[UsageTracker] 3MF fallback: previous archive lookup failed: %s", e)
return None
async def _track_from_3mf(
printer_id: int,
archive_id: int,
@@ -540,6 +665,8 @@ async def _track_from_3mf(
5. Position-based default using sorted available tray IDs (handles external spools)
6. Default mapping: slot_id - 1 = global_tray_id (last resort)
"""
from pathlib import Path
from backend.app.core.config import settings as app_settings
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
@@ -547,13 +674,24 @@ async def _track_from_3mf(
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
archive = result.scalar_one_or_none()
if not archive or not archive.file_path:
logger.info("[UsageTracker] 3MF: archive %s has no file_path, skipping", archive_id)
if not archive:
logger.info("[UsageTracker] 3MF: archive %s not found, skipping", archive_id)
return []
file_path = app_settings.base_dir / archive.file_path
if not file_path.exists():
logger.info("[UsageTracker] 3MF: file not found: %s", file_path)
file_path: Path | None = None
# Try archive's own file_path first
if archive.file_path:
candidate = app_settings.base_dir / archive.file_path
if candidate.exists():
file_path = candidate
# Fallback: find 3MF from library or a previous archive with the same filename
if file_path is None:
file_path = await _resolve_3mf_fallback(archive, db, app_settings.base_dir)
if file_path is None:
logger.info("[UsageTracker] 3MF: no file available for archive %s, skipping", archive_id)
return []
filament_usage = extract_filament_usage_from_3mf(file_path)