Ver Fonte

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).
maziggy há 4 semanas atrás
pai
commit
889c8bd87f

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
CHANGELOG.md


+ 39 - 16
backend/app/api/routes/printers.py

@@ -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")

+ 11 - 0
backend/app/services/background_dispatch.py

@@ -25,6 +25,7 @@ from backend.app.models.library import LibraryFile
 from backend.app.models.printer import Printer
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import (
+    cache_3mf_download,
     delete_file_async,
     get_ftp_retry_settings,
     upload_file_async,
@@ -684,6 +685,12 @@ class BackgroundDispatchService:
                     )
                     raise RuntimeError("Failed to start print")
 
+                # Register the archive's local 3MF in the cover-cache so the
+                # /cover endpoint can skip FTP — we already have the file on
+                # disk, no need to refetch 36 MB from a printer whose FTP is
+                # busy serving the active print (#1166 follow-up).
+                cache_3mf_download(job.printer_id, remote_filename, file_path)
+
                 # Wait for the printer to actually pick up the command before
                 # marking the dispatch job complete (#1042). MQTT-publish success
                 # only proves the command queued locally; the printer can still
@@ -884,6 +891,10 @@ class BackgroundDispatchService:
                     await db.rollback()
                     raise RuntimeError("Failed to start print")
 
+                # Same as the archive path: register the library file's local
+                # 3MF in the cover-cache so /cover skips FTP (#1166 follow-up).
+                cache_3mf_download(job.printer_id, remote_filename, file_path)
+
                 # See _run_reprint_archive for rationale (#1042). On timeout
                 # also rolls back the freshly-created archive so the library
                 # flow doesn't leave behind a phantom row for a print that

+ 18 - 0
backend/app/services/bambu_mqtt.py

@@ -193,6 +193,17 @@ class PrinterState:
     # Filament Track Switch (FTS) accessory — when installed, AMS info reports
     # bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
     fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
+    # Plate dispatched by Bambuddy for the current print. Some firmware versions
+    # (P1S 01.10.00.00) only put the .3mf filename in print.gcode_file, so the
+    # regex used to derive the plate number from the path always falls back to
+    # plate 1 — and the printer card shows the wrong thumbnail (#1166). When
+    # Bambuddy dispatches the print itself we know the plate authoritatively;
+    # we record it here and prefer it over the gcode_file regex. The subtask
+    # field guards against staleness: if the printer is currently running a
+    # different subtask (e.g. a Studio-direct dispatch), these values are
+    # ignored. Cleared on disconnect.
+    dispatched_plate_id: int | None = None
+    dispatched_subtask: str | None = None
     # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
     h2d_extruder_snow: dict = field(default_factory=dict)
@@ -3226,6 +3237,13 @@ class BambuMQTTClient:
 
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+            # Record what we dispatched so /cover can pick the right plate
+            # thumbnail even when the printer's gcode_file echo is just the
+            # 3MF filename without a plate path (#1166). Match the same
+            # subtask_name shape we send so the comparison in the cover route
+            # works against state.subtask_name reflected back via MQTT.
+            self.state.dispatched_plate_id = plate_id
+            self.state.dispatched_subtask = command["print"]["subtask_name"]
             return True
         else:
             # Log why we couldn't send the command

+ 13 - 1
backend/app/services/print_scheduler.py

@@ -20,7 +20,13 @@ from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
-from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
+from backend.app.services.bambu_ftp import (
+    cache_3mf_download,
+    delete_file_async,
+    get_ftp_retry_settings,
+    upload_file_async,
+    with_ftp_retry,
+)
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.smart_plug_manager import smart_plug_manager
@@ -1964,6 +1970,12 @@ class PrintScheduler:
         if started:
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
 
+            # Register the local 3MF in the cover-cache so /cover skips FTP
+            # (#1166 follow-up). file_path was resolved earlier from either the
+            # archive or the library file row.
+            if file_path is not None:
+                cache_3mf_download(item.printer_id, remote_filename, file_path)
+
             # Hold the printer against further dispatches until the watchdog
             # confirms the printer transitioned (or until the hard timeout).
             # Prevents multi-plate batches from triple-dispatching onto the

+ 24 - 1
backend/app/services/printer_manager.py

@@ -688,6 +688,29 @@ def parse_plate_id(gcode_file: str | None) -> int | None:
     return int(match.group(1)) if match else None
 
 
+def resolve_plate_id(state) -> int | None:
+    """Resolve the active plate number from a PrinterState.
+
+    Some firmware versions (e.g. P1S 01.10.00.00, #1166) put only the .3mf
+    filename in print.gcode_file, so parse_plate_id() returns None and the
+    printer card falls back to plate 1 — wrong thumbnail. When Bambuddy
+    dispatched the print itself we already know the right plate, so we prefer
+    that over the gcode_file echo. The subtask check prevents stale values
+    from a previous Bambuddy-dispatched print bleeding into a Studio-direct
+    print on the same printer.
+    """
+    dispatched_plate = getattr(state, "dispatched_plate_id", None)
+    dispatched_subtask = getattr(state, "dispatched_subtask", None)
+    if (
+        dispatched_plate is not None
+        and dispatched_subtask is not None
+        and state.subtask_name
+        and dispatched_subtask == state.subtask_name
+    ):
+        return dispatched_plate
+    return parse_plate_id(state.gcode_file)
+
+
 def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, model: str | None = None) -> dict:
     """Convert PrinterState to a JSON-serializable dict.
 
@@ -909,7 +932,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         # a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).
         # current_archive_id is intentionally REST-only — it's stable for the life
         # of a print and needs a DB lookup the WebSocket path shouldn't pay for.
-        "current_plate_id": parse_plate_id(state.gcode_file),
+        "current_plate_id": resolve_plate_id(state),
         # Plate-clear gate (#939). Lives on the PrinterManager rather than PrinterState,
         # so surface it here — without this, WebSocket merges drop the flag and the
         # "Clear Plate" button only appears when the 30 s REST fallback poll runs.

+ 88 - 0
backend/tests/integration/test_printers_api.py

@@ -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(

+ 69 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4393,6 +4393,75 @@ class TestHardResetClientDirect:
         assert mqtt_client._client is None
 
 
+class TestStartPrintRecordsDispatchedPlate:
+    """Tests for the dispatched-plate record set by start_print() — used by the
+    /cover route to pick the right thumbnail when the printer's gcode_file
+    echo doesn't include the plate path (#1166).
+
+    Some firmware versions (P1S 01.10.00.00) only put the .3mf filename in
+    print.gcode_file, so the regex falls back to plate 1 and the printer card
+    shows the wrong plate's thumbnail. Recording what we dispatched at the
+    publish site lets resolve_plate_id() return the right plate without
+    needing to introspect the 3MF.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def test_dispatched_plate_recorded_after_start_print(self, mqtt_client):
+        # Default state has no dispatched plate.
+        assert mqtt_client.state.dispatched_plate_id is None
+        assert mqtt_client.state.dispatched_subtask is None
+
+        mqtt_client.start_print("Luigi.3mf", plate_id=2)
+
+        # The subtask_name we record matches the one we send (and the printer
+        # reflects back via MQTT), so resolve_plate_id() can validate the
+        # match downstream.
+        assert mqtt_client.state.dispatched_plate_id == 2
+        assert mqtt_client.state.dispatched_subtask == "Luigi"
+
+    def test_dispatched_plate_default_is_one(self, mqtt_client):
+        # When start_print is called without plate_id (legacy/single-plate
+        # flow), we still record plate=1 — the contract is that dispatched_*
+        # describes the active dispatch.
+        mqtt_client.start_print("Single.3mf")
+        assert mqtt_client.state.dispatched_plate_id == 1
+        assert mqtt_client.state.dispatched_subtask == "Single"
+
+    def test_dispatched_plate_overwritten_by_subsequent_dispatch(self, mqtt_client):
+        # Each dispatch replaces the prior record so we can never serve a
+        # stale plate from an older print.
+        mqtt_client.start_print("First.3mf", plate_id=4)
+        mqtt_client.start_print("Second.3mf", plate_id=2)
+
+        assert mqtt_client.state.dispatched_plate_id == 2
+        assert mqtt_client.state.dispatched_subtask == "Second"
+
+    def test_dispatched_plate_not_recorded_when_publish_skipped(self, mqtt_client):
+        # If start_print early-returns because we're not connected, no record
+        # should land — otherwise the next print's /cover call would believe
+        # a phantom dispatch happened.
+        mqtt_client.state.connected = False
+        result = mqtt_client.start_print("Phantom.3mf", plate_id=3)
+
+        assert result is False
+        assert mqtt_client.state.dispatched_plate_id is None
+        assert mqtt_client.state.dispatched_subtask is None
+
+
 class TestFilamentTrackSwitchDetection:
     """Tests for Filament Track Switch (FTS) accessory detection (#1162).
 

+ 82 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -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

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff