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
@@ -301,6 +301,94 @@ class TestPrintersAPI:
assert result["fila_switch"]["stat"] == 0
assert result["fila_switch"]["info"] == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
self, async_client: AsyncClient, printer_factory, db_session, tmp_path
):
"""When firmware drops the plate path from gcode_file (e.g. P1S
01.10.00.00, #1166), the dispatched-plate record must take precedence
and serve plate 4's thumbnail instead of falling back to plate_1.png."""
import io
import zipfile
from unittest.mock import MagicMock, patch
from backend.app.services.bambu_ftp import cache_3mf_download
from backend.app.services.bambu_mqtt import PrinterState
printer = await printer_factory()
# Build a 3MF that mimics a "true" multi-plate archive: thumbnails
# for plates 1..4 are all present, gcode files for plates 1..4 are
# all present. Without the dispatch record we'd default to plate_1.png.
threemf_path = tmp_path / "MyModel.3mf"
with zipfile.ZipFile(threemf_path, "w") as zf:
for plate in range(1, 5):
zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
zf.writestr(f"Metadata/plate_{plate}.gcode", f"; plate {plate} gcode\n")
cache_3mf_download(printer.id, "MyModel.3mf", threemf_path)
state = PrinterState()
state.connected = True
state.state = "RUNNING"
state.subtask_name = "MyModel"
state.gcode_file = "MyModel.3mf" # firmware drops plate path
state.dispatched_plate_id = 4
state.dispatched_subtask = "MyModel"
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_status = MagicMock(return_value=state)
mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
assert response.status_code == 200
assert response.content == b"PLATE_4_PNG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cover_3mf_scan_fallback_for_per_plate_archive(
self, async_client: AsyncClient, printer_factory, db_session, tmp_path
):
"""Per-plate archives sliced separately in Bambu Studio contain a
single Metadata/plate_N.gcode (the active plate) but bundle thumbnails
for every plate. With no dispatch record (e.g. dispatched via Studio
directly) and no plate path in gcode_file, the route must scan the
3MF and pick plate N's thumbnail. See #1166 option 4."""
import zipfile
from unittest.mock import MagicMock, patch
from backend.app.services.bambu_ftp import cache_3mf_download
from backend.app.services.bambu_mqtt import PrinterState
printer = await printer_factory()
# Per-plate archive: thumbnails for all plates, gcode for plate 3 only.
threemf_path = tmp_path / "PerPlate.3mf"
with zipfile.ZipFile(threemf_path, "w") as zf:
for plate in range(1, 5):
zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
zf.writestr("Metadata/plate_3.gcode", "; only plate 3 has gcode\n")
cache_3mf_download(printer.id, "PerPlate.3mf", threemf_path)
state = PrinterState()
state.connected = True
state.state = "RUNNING"
state.subtask_name = "PerPlate"
state.gcode_file = "PerPlate.3mf"
# No dispatch record (Studio-direct dispatch).
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_status = MagicMock(return_value=state)
mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
assert response.status_code == 200
assert response.content == b"PLATE_3_PNG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_printer_status_omits_fila_switch_when_not_installed(