mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 04:01:21 +02:00
Second wave of #972 — reproducer on a 37.5 MB BambuStudio print to an A1 showed three stacking root causes when Bambuddy restarts mid-print. 1. Archive start_time lost on container restart. The name-based dedup cancelled any "printing" archive older than 4h and recreated it with started_at=now(), so a 13h print that saw a restart 10h in ended up showing ~1.5h duration. Persist MQTT subtask_id on every archive and match on that first, regardless of age — same id means same print, resume in place. Also revives Stale-cancelled rows for users upgrading mid-print. 2. 3MF FTP search tried non-existent paths for ~48 min. Order was /cache → /model → /data → /data/Metadata → / with 11×30s retries each; BambuStudio actually pushes to / on A1, so the real path was tested last. Reorder to / first, and raise a new FileNotOnPrinterError sentinel from download_to_file on 550 so with_ftp_retry short-circuits via non_retry_exceptions. 425 / SSL EOF / connection resets still retry as before. 3. Cover endpoint and archive flow downloaded the same 36 MB twice and competed for the printer's single FTP socket, producing 425 errors that fed cause-2's retry storm. Add an in-memory _threemf_path_cache keyed on (printer_id, normalized filename); whichever flow fetches first populates it, the other reuses the file read-only. Eviction runs on on_print_complete and deletes the temp file. Backend: 14 new tests across test_bambu_ftp.py and a new test_subtask_archive_resume.py. Existing suite: 2737 pass. ruff clean, frontend build clean.
93 lines
4.6 KiB
Python
93 lines
4.6 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from backend.app.core.database import Base
|
|
|
|
|
|
class PrintArchive(Base):
|
|
__tablename__ = "print_archives"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
|
|
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
|
|
|
|
# File info
|
|
filename: Mapped[str] = mapped_column(String(255))
|
|
file_path: Mapped[str] = mapped_column(String(500))
|
|
file_size: Mapped[int] = mapped_column(Integer)
|
|
content_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 hash for duplicate detection
|
|
thumbnail_path: Mapped[str | None] = mapped_column(String(500))
|
|
timelapse_path: Mapped[str | None] = mapped_column(String(500))
|
|
source_3mf_path: Mapped[str | None] = mapped_column(String(500)) # Original project 3MF from slicer
|
|
f3d_path: Mapped[str | None] = mapped_column(String(500)) # Fusion 360 design file
|
|
|
|
# Print details from 3MF / printer
|
|
print_name: Mapped[str | None] = mapped_column(String(255))
|
|
print_time_seconds: Mapped[int | None] = mapped_column(Integer)
|
|
filament_used_grams: Mapped[float | None] = mapped_column(Float)
|
|
filament_type: Mapped[str | None] = mapped_column(String(50))
|
|
filament_color: Mapped[str | None] = mapped_column(String(200))
|
|
layer_height: Mapped[float | None] = mapped_column(Float)
|
|
total_layers: Mapped[int | None] = mapped_column(Integer)
|
|
nozzle_diameter: Mapped[float | None] = mapped_column(Float)
|
|
bed_temperature: Mapped[int | None] = mapped_column(Integer)
|
|
nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
|
|
|
|
# Printer model this file was sliced for (extracted from 3MF metadata)
|
|
sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
|
|
# Print result
|
|
status: Mapped[str] = mapped_column(String(20), default="completed")
|
|
started_at: Mapped[datetime | None] = mapped_column(DateTime)
|
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
|
|
|
# Printer-assigned subtask identifier from MQTT. Used to resume the same
|
|
# archive row across a backend restart during a long-running print (#972):
|
|
# if the same subtask_id reappears after restart, we know it's the same
|
|
# print and keep the original row instead of cancel-then-create.
|
|
subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
# Extended metadata (JSON blob for flexibility)
|
|
extra_data: Mapped[dict | None] = mapped_column(JSON)
|
|
|
|
# MakerWorld info (auto-extracted from 3MF)
|
|
makerworld_url: Mapped[str | None] = mapped_column(String(500))
|
|
designer: Mapped[str | None] = mapped_column(String(255))
|
|
|
|
# User-defined external link (Printables, Thingiverse, etc.)
|
|
external_url: Mapped[str | None] = mapped_column(String(500))
|
|
|
|
# User additions
|
|
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
tags: Mapped[str | None] = mapped_column(Text)
|
|
notes: Mapped[str | None] = mapped_column(Text)
|
|
cost: Mapped[float | None] = mapped_column(Float)
|
|
photos: Mapped[list | None] = mapped_column(JSON) # List of photo filenames
|
|
failure_reason: Mapped[str | None] = mapped_column(String(100)) # For failed prints
|
|
quantity: Mapped[int] = mapped_column(Integer, default=1) # Number of items printed
|
|
|
|
# Energy tracking
|
|
energy_kwh: Mapped[float | None] = mapped_column(Float) # Energy consumed in kWh
|
|
energy_cost: Mapped[float | None] = mapped_column(Float) # Cost of energy consumed
|
|
# Plug lifetime counter captured at print start; delta at print end becomes energy_kwh.
|
|
# Persisted so per-print tracking survives backend restarts mid-print (#941).
|
|
energy_start_kwh: Mapped[float | None] = mapped_column(Float)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
|
|
# User tracking (who uploaded/created this archive)
|
|
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
|
|
# Relationships
|
|
printer: Mapped["Printer | None"] = relationship(back_populates="archives")
|
|
project: Mapped["Project | None"] = relationship(back_populates="archives")
|
|
created_by: Mapped["User | None"] = relationship()
|
|
|
|
|
|
from backend.app.models.printer import Printer # noqa: E402, F811
|
|
from backend.app.models.project import Project # noqa: E402, F811
|
|
from backend.app.models.user import User # noqa: E402, F811
|