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
@@ -1418,3 +1418,85 @@ class TestParsePlateId:
# wins. This matches real Bambu paths where the segment is preceded by
# arbitrary directory noise, and matches the equivalent frontend regex.
assert parse_plate_id("/uploads/project/plate_5.gcode.md5") == 5
class TestResolvePlateId:
"""Tests for resolve_plate_id() — plate resolution with dispatch precedence.
Regression coverage for #1166: P1S firmware 01.10.00.00 only puts the .3mf
filename in print.gcode_file, so parse_plate_id() returns None and the
printer card falls back to plate 1. When Bambuddy dispatches the print
itself we know the right plate; resolve_plate_id() prefers that record over
the gcode_file regex when subtask_name matches.
"""
def _make_state(self, **kwargs):
from backend.app.services.bambu_mqtt import PrinterState
state = PrinterState()
for k, v in kwargs.items():
setattr(state, k, v)
return state
def test_dispatched_plate_wins_when_subtask_matches(self):
# User dispatches plate 4 via Bambuddy. Printer reflects subtask_name
# but firmware drops the plate path from gcode_file. Without the dispatch
# record we'd default to plate 1.
from backend.app.services.printer_manager import resolve_plate_id
state = self._make_state(
gcode_file="MyModel.3mf", # No plate path — firmware bug
subtask_name="MyModel",
dispatched_plate_id=4,
dispatched_subtask="MyModel",
)
assert resolve_plate_id(state) == 4
def test_dispatched_ignored_when_subtask_differs(self):
# Bambuddy's dispatch record is for a previous print; the printer is
# now running a different subtask (Studio-direct dispatch). The stale
# record must not be used — fall back to gcode_file regex.
from backend.app.services.printer_manager import resolve_plate_id
state = self._make_state(
gcode_file="/Metadata/plate_2.gcode",
subtask_name="DifferentPrint",
dispatched_plate_id=4,
dispatched_subtask="MyModel",
)
assert resolve_plate_id(state) == 2
def test_falls_back_to_gcode_regex_without_dispatch(self):
# Studio-direct dispatch — no Bambuddy dispatch record. Existing logic
# (parse_plate_id on gcode_file) must still work.
from backend.app.services.printer_manager import resolve_plate_id
state = self._make_state(
gcode_file="/Metadata/plate_3.gcode",
subtask_name="MyModel",
)
assert resolve_plate_id(state) == 3
def test_returns_none_when_nothing_resolvable(self):
# No dispatch record AND firmware swallowed the plate path. The route
# uses this signal to invoke the 3MF-scan fallback.
from backend.app.services.printer_manager import resolve_plate_id
state = self._make_state(
gcode_file="MyModel.3mf",
subtask_name="MyModel",
)
assert resolve_plate_id(state) is None
def test_dispatched_subtask_required_to_avoid_false_match(self):
# dispatched_plate_id without dispatched_subtask is incomplete — we
# can't validate it points at the current print, so we ignore it.
from backend.app.services.printer_manager import resolve_plate_id
state = self._make_state(
gcode_file="MyModel.3mf",
subtask_name="MyModel",
dispatched_plate_id=4,
dispatched_subtask=None,
)
assert resolve_plate_id(state) is None