|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
import logging
|
|
import logging
|
|
|
import re
|
|
import re
|
|
|
import zipfile
|
|
import zipfile
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
|
from fastapi.responses import Response
|
|
from fastapi.responses import Response
|
|
@@ -1083,6 +1084,41 @@ def clear_cover_cache(printer_id: int) -> None:
|
|
|
_cover_404_cache.pop(printer_id, None)
|
|
_cover_404_cache.pop(printer_id, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _running_print_archive_file(printer_id: int, state) -> Path | None:
|
|
|
|
|
+ """Path to the 3MF of the print this printer is running, if we have it.
|
|
|
|
|
+
|
|
|
|
|
+ Bambuddy archives the sliced file when the print starts, so the copy the
|
|
|
|
|
+ printer is executing is usually already on disk. Anchored on ``subtask_id``,
|
|
|
|
|
+ which the firmware mints per print: a leftover ``status="printing"`` row from
|
|
|
|
|
+ a completion that was never seen must not lend its file to another job.
|
|
|
|
|
+
|
|
|
|
|
+ Opens its own short-lived session, like the caller does, so the pooled
|
|
|
|
|
+ connection is not held across the FTP work that follows.
|
|
|
|
|
+ """
|
|
|
|
|
+ subtask_id = str(getattr(state, "subtask_id", "") or "").strip()
|
|
|
|
|
+ if subtask_id in ("", "0"):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
|
|
+
|
|
|
|
|
+ async with database.async_session() as db:
|
|
|
|
|
+ archive = await db.scalar(
|
|
|
|
|
+ select(PrintArchive)
|
|
|
|
|
+ .where(
|
|
|
|
|
+ PrintArchive.printer_id == printer_id,
|
|
|
|
|
+ PrintArchive.status == "printing",
|
|
|
|
|
+ PrintArchive.subtask_id == subtask_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ .order_by(PrintArchive.created_at.desc())
|
|
|
|
|
+ .limit(1)
|
|
|
|
|
+ )
|
|
|
|
|
+ if archive is None or not archive.file_path:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ path = settings.base_dir / archive.file_path
|
|
|
|
|
+ return path if path.is_file() and str(path).endswith(".3mf") else None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@router.get("/{printer_id}/cover")
|
|
@router.get("/{printer_id}/cover")
|
|
|
async def get_printer_cover(
|
|
async def get_printer_cover(
|
|
|
printer_id: int,
|
|
printer_id: int,
|
|
@@ -1175,7 +1211,16 @@ async def get_printer_cover(
|
|
|
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
|
_cover_inflight[inflight_key] = fut
|
|
_cover_inflight[inflight_key] = fut
|
|
|
try:
|
|
try:
|
|
|
- image_data = await _produce_cover_image(printer, printer_id, subtask_name, view, view_key, plate_num, cache_key)
|
|
|
|
|
|
|
+ image_data = await _produce_cover_image(
|
|
|
|
|
+ printer,
|
|
|
|
|
+ printer_id,
|
|
|
|
|
+ subtask_name,
|
|
|
|
|
+ view,
|
|
|
|
|
+ view_key,
|
|
|
|
|
+ plate_num,
|
|
|
|
|
+ cache_key,
|
|
|
|
|
+ archive_path=await _running_print_archive_file(printer_id, state),
|
|
|
|
|
+ )
|
|
|
return Response(content=image_data, media_type="image/png")
|
|
return Response(content=image_data, media_type="image/png")
|
|
|
finally:
|
|
finally:
|
|
|
if not fut.done():
|
|
if not fut.done():
|
|
@@ -1191,6 +1236,7 @@ async def _produce_cover_image(
|
|
|
view_key: str,
|
|
view_key: str,
|
|
|
plate_num: int | None,
|
|
plate_num: int | None,
|
|
|
cache_key: tuple[str, str],
|
|
cache_key: tuple[str, str],
|
|
|
|
|
+ archive_path: Path | None = None,
|
|
|
) -> bytes:
|
|
) -> bytes:
|
|
|
"""Download the active-print 3MF and extract its cover thumbnail (#2572).
|
|
"""Download the active-print 3MF and extract its cover thumbnail (#2572).
|
|
|
|
|
|
|
@@ -1198,7 +1244,9 @@ async def _produce_cover_image(
|
|
|
can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
|
|
can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
|
|
|
on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
|
|
on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
|
|
|
failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
|
|
failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
|
|
|
- work — the caller already released the pooled connection before this runs.
|
|
|
|
|
|
|
+ work — the caller already released the pooled connection before this runs,
|
|
|
|
|
+ which is also why ``archive_path`` arrives resolved rather than looked up
|
|
|
|
|
+ here.
|
|
|
"""
|
|
"""
|
|
|
# Build possible 3MF filenames from subtask_name
|
|
# Build possible 3MF filenames from subtask_name
|
|
|
# Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
|
|
# Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
|
|
@@ -1260,6 +1308,20 @@ async def _produce_cover_image(
|
|
|
using_cached = True
|
|
using_cached = True
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
|
|
+ if not downloaded:
|
|
|
|
|
+ # Same idea, one step further back: that in-memory cache dies with the
|
|
|
|
|
+ # process, but the archive of the print that is still running holds the
|
|
|
|
|
+ # very 3MF on disk. Without this, reopening a card or the skip-objects
|
|
|
|
|
+ # plate after a restart pulls the whole file back off a printer that is
|
|
|
|
|
+ # mid-print — measured at three concurrent fan-outs, thirteen seconds
|
|
|
|
|
+ # and a 0-byte read on the maintainer's H2C, which is exactly the
|
|
|
|
|
+ # single-socket contention #972 was about.
|
|
|
|
|
+ if archive_path is not None:
|
|
|
|
|
+ logger.info("Cover using the running print's archived 3MF at %s (no FTP)", archive_path)
|
|
|
|
|
+ temp_path = archive_path
|
|
|
|
|
+ downloaded = True
|
|
|
|
|
+ using_cached = True
|
|
|
|
|
+
|
|
|
if not downloaded:
|
|
if not downloaded:
|
|
|
# The cover lives inside the 3MF, so it is only reachable if the 3MF is.
|
|
# The cover lives inside the 3MF, so it is only reachable if the 3MF is.
|
|
|
# When the printer kept the print on internal storage there is nothing
|
|
# When the printer kept the print on internal storage there is nothing
|
|
@@ -3663,6 +3725,47 @@ async def get_printable_objects(
|
|
|
|
|
|
|
|
# Reload objects from 3MF if requested or no objects loaded
|
|
# Reload objects from 3MF if requested or no objects loaded
|
|
|
if reload or not client.state.printable_objects:
|
|
if reload or not client.state.printable_objects:
|
|
|
|
|
+ # The archive of a running print normally holds the very file the
|
|
|
|
|
+ # printer is executing, so ask the disk before asking the printer:
|
|
|
|
|
+ # the fan-out below pulls the whole 3MF over FTPS from a machine that
|
|
|
|
|
+ # is mid-print — 15 MB on the print this was written for — and on a
|
|
|
|
|
+ # printer that kept the file on internal storage it cannot succeed at
|
|
|
|
|
+ # all. skipped_objects is deliberately left alone: a reload is
|
|
|
|
|
+ # not a new print, and the list of what the user already skipped only
|
|
|
|
|
+ # lives here.
|
|
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
|
|
+ from backend.app.services.archive import extract_printable_objects_from_archive
|
|
|
|
|
+
|
|
|
|
|
+ subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip()
|
|
|
|
|
+ if subtask_id not in ("", "0"):
|
|
|
|
|
+ archive = await db.scalar(
|
|
|
|
|
+ select(PrintArchive)
|
|
|
|
|
+ .where(
|
|
|
|
|
+ PrintArchive.printer_id == printer_id,
|
|
|
|
|
+ PrintArchive.status == "printing",
|
|
|
|
|
+ PrintArchive.subtask_id == subtask_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ .order_by(PrintArchive.created_at.desc())
|
|
|
|
|
+ .limit(1)
|
|
|
|
|
+ )
|
|
|
|
|
+ if archive is not None:
|
|
|
|
|
+ objects, bbox_all = extract_printable_objects_from_archive(
|
|
|
|
|
+ settings.base_dir / archive.file_path,
|
|
|
|
|
+ plate_number=resolve_plate_id(client.state),
|
|
|
|
|
+ )
|
|
|
|
|
+ if objects:
|
|
|
|
|
+ client.state.printable_objects = objects
|
|
|
|
|
+ client.state.printable_objects_bbox_all = bbox_all
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "Reloaded %s objects for printer %s from archive %s",
|
|
|
|
|
+ len(objects),
|
|
|
|
|
+ printer_id,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # Only when the disk could not answer: a `reload=true` that the archive
|
|
|
|
|
+ # satisfied has already refreshed from the file the printer is running.
|
|
|
|
|
+ if not client.state.printable_objects:
|
|
|
subtask_name = client.state.subtask_name
|
|
subtask_name = client.state.subtask_name
|
|
|
if subtask_name:
|
|
if subtask_name:
|
|
|
from backend.app.services.archive import extract_printable_objects_from_3mf
|
|
from backend.app.services.archive import extract_printable_objects_from_3mf
|