fix(printers): show correct plate thumbnail on multi-plate 3MFs (#1166)

P1S 01.10.00.00 (and similar firmware revisions) only echo the .3mf
  filename in print.gcode_file, dropping the Metadata/plate_N.gcode path.
  The /cover route's regex falls back to plate 1 — and the printer card
  shows the wrong plate's thumbnail on multi-plate prints.

  Resolution order in the new resolve_plate_id() helper (used by both
  the status route's current_plate_id and /cover):

  1. The plate Bambuddy dispatched. start_print() now records
     (dispatched_plate_id, dispatched_subtask) on PrinterState; the
     subtask check rejects stale records from a previous Bambuddy
     dispatch bleeding into a Studio-direct print on the same project.
  2. plate_(\d+)\.gcode regex on state.gcode_file (existing behaviour
     for firmware that does include the path).
  3. After download, scan the 3MF for a unique Metadata/plate_*.gcode —
     covers per-plate archives sliced separately in Studio without a
     Bambuddy dispatch record.
  4. Default to plate 1.

  Cover-byte cache key simplified to (subtask_name, view_key) now that
  plate resolution is late-bound. clear_cover_cache() already fires on
  every print start, so re-dispatches with a different plate always
  fetch a fresh thumbnail.

  Bambuddy-dispatched prints additionally register the local archive
  3MF in the cover cache at dispatch time, so /cover reads straight
  from the archive directory and doesn't refetch the file over FTP
  from a printer whose FTP server is busy serving the active print.

  Coverage: 5 unit tests for resolve_plate_id, 4 unit tests for the
  dispatch record on start_print, 2 integration tests for the cover
  route (dispatch wins over plate-1 default; 3MF-scan fallback for
  per-plate archive without dispatch record).
This commit is contained in:
maziggy
2026-04-29 16:51:38 +02:00
parent b45ca2a662
commit 889c8bd87f
9 changed files with 346 additions and 18 deletions
+39 -16
View File
@@ -40,8 +40,8 @@ from backend.app.services.bambu_ftp import (
)
from backend.app.services.printer_manager import (
get_derived_status_name,
parse_plate_id,
printer_manager,
resolve_plate_id,
supports_chamber_temp,
supports_drying,
)
@@ -570,7 +570,7 @@ async def get_printer_status(
current_archive_id: int | None = None
current_plate_id: int | None = None
if state.state in ("RUNNING", "PAUSE"):
current_plate_id = parse_plate_id(state.gcode_file)
current_plate_id = resolve_plate_id(state)
if state.subtask_id:
from backend.app.models.archive import PrintArchive
@@ -738,7 +738,9 @@ async def test_printer_connection(
return result
# Cache for cover images (printer_id -> {(subtask_name, plate_num, view) -> image_bytes})
# Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
# Cleared on every print start by main.py::on_print_start, so re-dispatches with
# different plates always fetch a fresh thumbnail without needing plate in the key.
_cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
@@ -774,21 +776,28 @@ async def get_printer_cover(
if not subtask_name:
raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
# Extract plate number from gcode_file (e.g., "/data/Metadata/plate_12.gcode" -> 12)
plate_num = 1
gcode_file = state.gcode_file
if gcode_file:
match = re.search(r"plate_(\d+)\.gcode", gcode_file)
if match:
plate_num = int(match.group(1))
logger.info("Detected plate number %s from gcode_file: %s", plate_num, gcode_file)
# Resolve the active plate. Precedence (#1166):
# 1. The plate Bambuddy dispatched (authoritative when we sent the print)
# 2. plate_(\d+)\.gcode regex on state.gcode_file (works on firmware that
# reflects the full path, e.g. some X1C builds)
# 3. Scan the downloaded 3MF for a unique Metadata/plate_*.gcode (covers
# per-plate archives sliced separately in Bambu Studio, where the
# printer's gcode_file echo is just the .3mf filename)
# 4. Fall back to plate 1
# The 3MF-scan fallback runs later — after the file is on disk.
plate_num = resolve_plate_id(state)
if plate_num is not None:
logger.info("Cover: resolved plate %s before download (subtask=%s)", plate_num, subtask_name)
# Normalize view parameter
view_key = view or "default"
# Check cache - include plate_num in cache key for multi-plate projects
# Check cache. Cache by (subtask_name, view_key) only — clear_cover_cache()
# runs on every print start, so a re-dispatch with a different plate gets
# a fresh image regardless. Pre-#1166 the key included plate_num, but with
# late plate resolution the cache check would always miss.
if printer_id in _cover_cache:
cache_key = (subtask_name, plate_num, view_key)
cache_key = (subtask_name, view_key)
if cache_key in _cover_cache[printer_id]:
return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
@@ -907,6 +916,21 @@ async def get_printer_cover(
raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
try:
# 3MF-scan fallback for plate detection (#1166). Per-plate archives
# sliced separately in Bambu Studio contain a single
# Metadata/plate_N.gcode for the active plate, even though
# thumbnails for all plates are bundled. Using that gcode's plate
# number prevents falling back to plate_1.png.
if plate_num is None:
plate_gcodes = [name for name in zf.namelist() if re.match(r"^Metadata/plate_\d+\.gcode$", name)]
if len(plate_gcodes) == 1:
match = re.search(r"plate_(\d+)\.gcode", plate_gcodes[0])
if match:
plate_num = int(match.group(1))
logger.info("Cover: detected plate %s from 3MF contents", plate_num)
if plate_num is None:
plate_num = 1
# Try common thumbnail paths in 3MF files
# Use plate_num to get the correct plate's thumbnail for multi-plate projects
# Use top-down view if requested (better for skip objects modal)
@@ -934,10 +958,9 @@ async def get_printer_cover(
for thumb_path in thumbnail_paths:
try:
image_data = zf.read(thumb_path)
# Cache the result - include plate_num in cache key
if printer_id not in _cover_cache:
_cover_cache[printer_id] = {}
_cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
_cover_cache[printer_id][(subtask_name, view_key)] = image_data
return Response(content=image_data, media_type="image/png")
except KeyError:
continue
@@ -948,7 +971,7 @@ async def get_printer_cover(
image_data = zf.read(name)
if printer_id not in _cover_cache:
_cover_cache[printer_id] = {}
_cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
_cover_cache[printer_id][(subtask_name, view_key)] = image_data
return Response(content=image_data, media_type="image/png")
raise HTTPException(404, "No thumbnail found in 3MF file")