|
|
@@ -6376,6 +6376,46 @@ async def prime_kprofile_table(printer_id: int) -> int:
|
|
|
return primed
|
|
|
|
|
|
|
|
|
+def _sd_files_in_use(state) -> set[str]:
|
|
|
+ """Names of the SD-card files the printer may be printing right now (#3009).
|
|
|
+
|
|
|
+ The post-print cleanup works out what to delete from the finished archive,
|
|
|
+ not from the printer. When reconciliation closes an old archive because the
|
|
|
+ printer has moved on to a new job, and that job is the same file reprinted
|
|
|
+ from the printer's screen, the cleanup would delete the file mid-print.
|
|
|
+
|
|
|
+ Empty unless the printer is busy: on an ordinary completion the state is
|
|
|
+ FINISH or FAILED and nothing is held back. ``gcode_file`` arrives as a bare
|
|
|
+ name, a path or a URL depending on firmware, and some report only the
|
|
|
+ plate's G-code, so the job name is matched as well, in the same spellings
|
|
|
+ the cleanup tries. Names are lower-cased; the card's FAT filesystem
|
|
|
+ ignores case.
|
|
|
+ """
|
|
|
+
|
|
|
+ def _text(value) -> str:
|
|
|
+ return value.strip() if isinstance(value, str) else ""
|
|
|
+
|
|
|
+ if state is None:
|
|
|
+ return set()
|
|
|
+ current_state = _text(getattr(state, "state", None)).upper()
|
|
|
+ if current_state in ("", "UNKNOWN", "IDLE", "FINISH", "FAILED"):
|
|
|
+ return set()
|
|
|
+ in_use: set[str] = set()
|
|
|
+ gcode_file = _text(getattr(state, "gcode_file", None))
|
|
|
+ if gcode_file:
|
|
|
+ # Drop a scheme by hand: urlparse would cut a name at "#" or "?",
|
|
|
+ # both legal in a file name, and raises on some inputs.
|
|
|
+ name = PurePosixPath(gcode_file.split("://", 1)[-1]).name
|
|
|
+ if name:
|
|
|
+ in_use.add(name.lower())
|
|
|
+ subtask_name = _text(getattr(state, "subtask_name", None))
|
|
|
+ if subtask_name:
|
|
|
+ for name in (subtask_name, subtask_name.replace(" ", "_")):
|
|
|
+ for ext in (".3mf", ".gcode"):
|
|
|
+ in_use.add(f"{name}{ext}".lower())
|
|
|
+ return in_use
|
|
|
+
|
|
|
+
|
|
|
async def reconcile_stale_active_prints(printer_id: int) -> int:
|
|
|
"""Synthesise ``on_print_complete`` for archives whose print can't be
|
|
|
running on the printer anymore.
|
|
|
@@ -7029,6 +7069,121 @@ async def _recover_fallback_from_cache_before_eviction(printer_id: int, data: di
|
|
|
logger.debug("[RECOVER] Pre-eviction recovery for %s failed: %s", name, e)
|
|
|
|
|
|
|
|
|
+async def _cleanup_sd_card_after_print(
|
|
|
+ printer_id: int, subtask_name: str | None, archive_id: int | None, logger
|
|
|
+) -> None:
|
|
|
+ """Delete a finished print's file from the printer's SD card."""
|
|
|
+ # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374, #1542)
|
|
|
+ # The print scheduler uploads files to the SD card root (/). Some printers (e.g. P1S, A1)
|
|
|
+ # auto-start files found in root on power cycle, causing ghost prints.
|
|
|
+ # Must run before the archive_id early-return so it executes even when archiving is disabled.
|
|
|
+ try:
|
|
|
+ if subtask_name:
|
|
|
+ archive_filename: str | None = None
|
|
|
+ async with async_session() as db:
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
+ from backend.app.models.printer import Printer
|
|
|
+
|
|
|
+ result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
+ printer = result.scalar_one_or_none()
|
|
|
+ if archive_id:
|
|
|
+ archive_row = await db.execute(select(PrintArchive.filename).where(PrintArchive.id == archive_id))
|
|
|
+ archive_filename = archive_row.scalar_one_or_none()
|
|
|
+
|
|
|
+ if printer:
|
|
|
+ from backend.app.services.bambu_ftp import DeleteResult, delete_file_async
|
|
|
+ from backend.app.utils.filename import derive_remote_filename
|
|
|
+
|
|
|
+ # Primary candidate: the exact path the dispatcher uploaded to
|
|
|
+ # (derived from archive.filename via the same rule as upload).
|
|
|
+ # Without it, a library row that ended up with a doubled
|
|
|
+ # .gcode.3mf (#1542) leaves the real file behind because the
|
|
|
+ # subtask_name + ext fallbacks below don't match what's on the
|
|
|
+ # SD card. Fallbacks remain for archive-less prints (subtask
|
|
|
+ # never resolved to an archive) and for older naming variants.
|
|
|
+ candidate_paths: list[str] = []
|
|
|
+ if archive_filename:
|
|
|
+ candidate_paths.append(f"/{derive_remote_filename(archive_filename)}")
|
|
|
+ for ext in (".3mf", ".gcode"):
|
|
|
+ fallback = f"/{subtask_name}{ext}"
|
|
|
+ if fallback not in candidate_paths:
|
|
|
+ candidate_paths.append(fallback)
|
|
|
+
|
|
|
+ # Three outcomes track across all candidates so the final log
|
|
|
+ # line reflects what actually happened. The A1 in #1721 always
|
|
|
+ # ends here with ``any_not_found=True`` and the others False
|
|
|
+ # — its firmware auto-cleans the SD card before our cleanup
|
|
|
+ # runs, every candidate FTP-DELE returns 550, and the old
|
|
|
+ # code burned 3 retries × 2 s × 3 candidates per print
|
|
|
+ # logging a misleading "may linger" WARNING on a successful
|
|
|
+ # print.
|
|
|
+ any_deleted = False
|
|
|
+ any_real_failure = False
|
|
|
+ any_not_found = False
|
|
|
+
|
|
|
+ files_in_use = _sd_files_in_use(printer_manager.get_status(printer_id))
|
|
|
+
|
|
|
+ for remote_path in candidate_paths:
|
|
|
+ if PurePosixPath(remote_path).name.lower() in files_in_use:
|
|
|
+ logger.info(
|
|
|
+ "SD card cleanup: keeping %s on printer %s, which is printing it now",
|
|
|
+ remote_path,
|
|
|
+ printer.name,
|
|
|
+ )
|
|
|
+ continue
|
|
|
+ # Retry only the FAILED case — 550 NOT_FOUND will never
|
|
|
+ # recover by waiting, so a "file isn't here" answer
|
|
|
+ # advances immediately to the next candidate without
|
|
|
+ # consuming the retry budget.
|
|
|
+ for attempt in range(1, 4):
|
|
|
+ try:
|
|
|
+ delete_result = await delete_file_async(
|
|
|
+ printer.ip_address,
|
|
|
+ printer.access_code,
|
|
|
+ remote_path,
|
|
|
+ printer_model=printer.model,
|
|
|
+ )
|
|
|
+ except Exception as e:
|
|
|
+ delete_result = DeleteResult.FAILED
|
|
|
+ logger.warning(
|
|
|
+ "SD card cleanup attempt %d/3 raised for %s: %s",
|
|
|
+ attempt,
|
|
|
+ remote_path,
|
|
|
+ e,
|
|
|
+ )
|
|
|
+
|
|
|
+ if delete_result == DeleteResult.DELETED:
|
|
|
+ any_deleted = True
|
|
|
+ logger.info("Deleted %s from printer %s SD card", remote_path, printer.name)
|
|
|
+ break
|
|
|
+ if delete_result == DeleteResult.NOT_FOUND:
|
|
|
+ any_not_found = True
|
|
|
+ break # 550 will not recover; try next candidate
|
|
|
+ # FAILED: real error — retry with backoff, then give up
|
|
|
+ if attempt < 3:
|
|
|
+ await asyncio.sleep(2)
|
|
|
+ else:
|
|
|
+ any_real_failure = True
|
|
|
+ logger.warning(
|
|
|
+ "SD card cleanup failed after 3 attempts for %s "
|
|
|
+ "(network/auth/transient error — file may linger on SD card)",
|
|
|
+ remote_path,
|
|
|
+ )
|
|
|
+
|
|
|
+ if not any_deleted and not any_real_failure and any_not_found:
|
|
|
+ # Every candidate said "not here." Either the printer
|
|
|
+ # firmware swept the SD card itself (common on A1) or the
|
|
|
+ # dispatcher's upload path doesn't match our candidate
|
|
|
+ # rule. Either way: nothing to clean up, no warning.
|
|
|
+ logger.debug(
|
|
|
+ "SD card cleanup: nothing to delete on %s — every candidate returned 550 "
|
|
|
+ "(printer likely self-cleaned)",
|
|
|
+ printer.name,
|
|
|
+ )
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("SD card file cleanup failed for printer %s: %s", printer_id, e)
|
|
|
+
|
|
|
+
|
|
|
async def on_print_complete(printer_id: int, data: dict):
|
|
|
"""Handle print completion - update the archive status."""
|
|
|
import time
|
|
|
@@ -7222,106 +7377,7 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
if archive:
|
|
|
archive_id = archive.id
|
|
|
|
|
|
- # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374, #1542)
|
|
|
- # The print scheduler uploads files to the SD card root (/). Some printers (e.g. P1S, A1)
|
|
|
- # auto-start files found in root on power cycle, causing ghost prints.
|
|
|
- # Must run before the archive_id early-return so it executes even when archiving is disabled.
|
|
|
- try:
|
|
|
- if subtask_name:
|
|
|
- archive_filename: str | None = None
|
|
|
- async with async_session() as db:
|
|
|
- from backend.app.models.archive import PrintArchive
|
|
|
- from backend.app.models.printer import Printer
|
|
|
-
|
|
|
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
- printer = result.scalar_one_or_none()
|
|
|
- if archive_id:
|
|
|
- archive_row = await db.execute(select(PrintArchive.filename).where(PrintArchive.id == archive_id))
|
|
|
- archive_filename = archive_row.scalar_one_or_none()
|
|
|
-
|
|
|
- if printer:
|
|
|
- from backend.app.services.bambu_ftp import DeleteResult, delete_file_async
|
|
|
- from backend.app.utils.filename import derive_remote_filename
|
|
|
-
|
|
|
- # Primary candidate: the exact path the dispatcher uploaded to
|
|
|
- # (derived from archive.filename via the same rule as upload).
|
|
|
- # Without it, a library row that ended up with a doubled
|
|
|
- # .gcode.3mf (#1542) leaves the real file behind because the
|
|
|
- # subtask_name + ext fallbacks below don't match what's on the
|
|
|
- # SD card. Fallbacks remain for archive-less prints (subtask
|
|
|
- # never resolved to an archive) and for older naming variants.
|
|
|
- candidate_paths: list[str] = []
|
|
|
- if archive_filename:
|
|
|
- candidate_paths.append(f"/{derive_remote_filename(archive_filename)}")
|
|
|
- for ext in (".3mf", ".gcode"):
|
|
|
- fallback = f"/{subtask_name}{ext}"
|
|
|
- if fallback not in candidate_paths:
|
|
|
- candidate_paths.append(fallback)
|
|
|
-
|
|
|
- # Three outcomes track across all candidates so the final log
|
|
|
- # line reflects what actually happened. The A1 in #1721 always
|
|
|
- # ends here with ``any_not_found=True`` and the others False
|
|
|
- # — its firmware auto-cleans the SD card before our cleanup
|
|
|
- # runs, every candidate FTP-DELE returns 550, and the old
|
|
|
- # code burned 3 retries × 2 s × 3 candidates per print
|
|
|
- # logging a misleading "may linger" WARNING on a successful
|
|
|
- # print.
|
|
|
- any_deleted = False
|
|
|
- any_real_failure = False
|
|
|
- any_not_found = False
|
|
|
-
|
|
|
- for remote_path in candidate_paths:
|
|
|
- # Retry only the FAILED case — 550 NOT_FOUND will never
|
|
|
- # recover by waiting, so a "file isn't here" answer
|
|
|
- # advances immediately to the next candidate without
|
|
|
- # consuming the retry budget.
|
|
|
- for attempt in range(1, 4):
|
|
|
- try:
|
|
|
- delete_result = await delete_file_async(
|
|
|
- printer.ip_address,
|
|
|
- printer.access_code,
|
|
|
- remote_path,
|
|
|
- printer_model=printer.model,
|
|
|
- )
|
|
|
- except Exception as e:
|
|
|
- delete_result = DeleteResult.FAILED
|
|
|
- logger.warning(
|
|
|
- "SD card cleanup attempt %d/3 raised for %s: %s",
|
|
|
- attempt,
|
|
|
- remote_path,
|
|
|
- e,
|
|
|
- )
|
|
|
-
|
|
|
- if delete_result == DeleteResult.DELETED:
|
|
|
- any_deleted = True
|
|
|
- logger.info("Deleted %s from printer %s SD card", remote_path, printer.name)
|
|
|
- break
|
|
|
- if delete_result == DeleteResult.NOT_FOUND:
|
|
|
- any_not_found = True
|
|
|
- break # 550 will not recover; try next candidate
|
|
|
- # FAILED: real error — retry with backoff, then give up
|
|
|
- if attempt < 3:
|
|
|
- await asyncio.sleep(2)
|
|
|
- else:
|
|
|
- any_real_failure = True
|
|
|
- logger.warning(
|
|
|
- "SD card cleanup failed after 3 attempts for %s "
|
|
|
- "(network/auth/transient error — file may linger on SD card)",
|
|
|
- remote_path,
|
|
|
- )
|
|
|
-
|
|
|
- if not any_deleted and not any_real_failure and any_not_found:
|
|
|
- # Every candidate said "not here." Either the printer
|
|
|
- # firmware swept the SD card itself (common on A1) or the
|
|
|
- # dispatcher's upload path doesn't match our candidate
|
|
|
- # rule. Either way: nothing to clean up, no warning.
|
|
|
- logger.debug(
|
|
|
- "SD card cleanup: nothing to delete on %s — every candidate returned 550 "
|
|
|
- "(printer likely self-cleaned)",
|
|
|
- printer.name,
|
|
|
- )
|
|
|
- except Exception as e:
|
|
|
- logger.warning("SD card file cleanup failed for printer %s: %s", printer_id, e)
|
|
|
+ await _cleanup_sd_card_after_print(printer_id, subtask_name, archive_id, logger)
|
|
|
|
|
|
log_timing("SD card cleanup")
|
|
|
|