Просмотр исходного кода

Raise the plate for the finish photo from the print's own archive (#3240)

maziggy 1 день назад
Родитель
Сommit
24c5c12623
3 измененных файлов с 380 добавлено и 133 удалено
  1. 1 0
      CHANGELOG.md
  2. 151 64
      backend/app/main.py
  3. 228 69
      backend/tests/unit/test_finish_photo_moment_sync.py

+ 1 - 0
CHANGELOG.md

@@ -138,6 +138,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **The finish photo never raised the plate back into view (#3240, reported by @Thomansky)** — Since 1.2.5.2, Bambuddy is meant to raise the build plate back to just above the finished print before the finish photo, then lower it again, because the printer's end G-code drops the plate about 100 mm. It never did, on any printer: the lookup for the print's height failed every time and logged nothing above debug level. It read the archived file from a setting that does not exist, and it matched the archive by exact name, while the printer reports a name with its spaces turned into underscores. Both are fixed, so the plate now really moves after a print when **Restore plate for finish photo** is on, which it is by default. Turn it off under **Settings** > **General** to keep the photo where the plate stops. Because the plate now really moves, the print's height is taken only from the archive Bambuddy linked to that print when it started, and from the plate that was sent, never from another archive that merely has the same name: every plate of a multi-plate file shares one name, and support bundles showed the old lookup picking another plate's archive, stopped only by its layer count. That archive must also match the print's name exactly, never part of it or a shortened version; its layer count must agree with the printer's; and the plate stays put when another job is queued for the printer or the print was not linked to an archive.
 - **An "Any model" job was held for too little filament on one printer while another idle printer of that model had plenty (#3137, reported by @RambachTJ)** — The queue gave a job queued for **Any H2D Pro** (or any model) to the first idle printer with the right filament type, and only then checked whether its spools held enough. When they did not, the job waited on that printer for a manual start, even though another idle printer of the same model could run it; the only way out was to move it by hand. The queue now passes over a printer that would run short and gives the job to one that has enough. If every idle printer would run short, the job still waits on the first, where **Print Anyway** works as before, and moves by itself once another idle printer of its model has enough filament, on record in Inventory or Spoolman.
 - **An "Any model" job was held for too little filament on one printer while another idle printer of that model had plenty (#3137, reported by @RambachTJ)** — The queue gave a job queued for **Any H2D Pro** (or any model) to the first idle printer with the right filament type, and only then checked whether its spools held enough. When they did not, the job waited on that printer for a manual start, even though another idle printer of the same model could run it; the only way out was to move it by hand. The queue now passes over a printer that would run short and gives the job to one that has enough. If every idle printer would run short, the job still waits on the first, where **Print Anyway** works as before, and moves by itself once another idle printer of its model has enough filament, on record in Inventory or Spoolman.
 - **Shutting Bambuddy down logged an error with a traceback when nothing was wrong (#3243, reported by @Thomansky)** — Stopping Bambuddy cancels the printer updates still waiting to be handled, and each cancelled one was logged as `ERROR ... Exception in scheduled callback` with a traceback, as the last line before the restart. In a log or a support bundle it looked like a crash. A cancelled update now ends quietly; one that actually fails is still logged as an error.
 - **Shutting Bambuddy down logged an error with a traceback when nothing was wrong (#3243, reported by @Thomansky)** — Stopping Bambuddy cancels the printer updates still waiting to be handled, and each cancelled one was logged as `ERROR ... Exception in scheduled callback` with a traceback, as the last line before the restart. In a log or a support bundle it looked like a crash. A cancelled update now ends quietly; one that actually fails is still logged as an error.
 - **H2D, H2D Pro and H2C with several AMS on one extruder marked the wrong tray as loaded on every filament change (#3242, reported by @Thomansky)** — These printers report the loading tray as a slot number (0-3) without saying which AMS it is in, and the report that says which AMS is read a moment later. In that gap Bambuddy assumed AMS 0: during a print feeding from AMS 2 slot 1, AMS 0 slot 1 showed as loaded for about a second, and that tray went into the list of tray changes that splits a print's filament between spools. Support packages show this on every filament change.
 - **H2D, H2D Pro and H2C with several AMS on one extruder marked the wrong tray as loaded on every filament change (#3242, reported by @Thomansky)** — These printers report the loading tray as a slot number (0-3) without saying which AMS it is in, and the report that says which AMS is read a moment later. In that gap Bambuddy assumed AMS 0: during a print feeding from AMS 2 slot 1, AMS 0 slot 1 showed as loaded for about a second, and that tray went into the list of tray changes that splits a print's filament between spools. Support packages show this on every filament change.

+ 151 - 64
backend/app/main.py

@@ -6532,7 +6532,99 @@ _PLATE_RESTORE_SETTLE_SECONDS = 12.0
 _FINISH_PHOTO_PRODUCER_WAIT_SECONDS = _PLATE_RESTORE_SETTLE_SECONDS + 23.0
 _FINISH_PHOTO_PRODUCER_WAIT_SECONDS = _PLATE_RESTORE_SETTLE_SECONDS + 23.0
 
 
 
 
-async def _max_z_for_current_print(printer_id: int, data: dict, logger) -> float | None:
+def _completion_print_keys(printer_id: int, filename: str, subtask_name: str) -> list[tuple[int, str]]:
+    """The ``_active_prints`` keys a finished print may be registered under.
+
+    Matching how they were registered in on_print_start. Shared by
+    ``on_print_complete`` and the finish-photo plate restore (#3240), which has
+    to find the same binding.
+    """
+    possible_keys = []
+
+    # Try subtask_name variations first (most reliable for matching)
+    if subtask_name:
+        possible_keys.append((printer_id, f"{subtask_name}.3mf"))
+        possible_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
+        possible_keys.append((printer_id, subtask_name))
+
+    # Try filename variations
+    if filename:
+        # Extract just the filename if it's a path
+        fname = filename.split("/")[-1] if "/" in filename else filename
+
+        if fname.endswith(".3mf"):
+            possible_keys.append((printer_id, fname))
+        elif fname.endswith(".gcode"):
+            base_name = fname.rsplit(".", 1)[0]
+            possible_keys.append((printer_id, f"{base_name}.gcode.3mf"))
+            possible_keys.append((printer_id, f"{base_name}.3mf"))
+            possible_keys.append((printer_id, fname))
+        else:
+            possible_keys.append((printer_id, f"{fname}.gcode.3mf"))
+            possible_keys.append((printer_id, f"{fname}.3mf"))
+            possible_keys.append((printer_id, fname))
+
+        # Also try full path versions
+        if filename.endswith(".3mf"):
+            possible_keys.append((printer_id, filename))
+        elif filename.endswith(".gcode"):
+            base_name = filename.rsplit(".", 1)[0]
+            possible_keys.append((printer_id, f"{base_name}.3mf"))
+            possible_keys.append((printer_id, filename))
+        else:
+            possible_keys.append((printer_id, f"{filename}.3mf"))
+            possible_keys.append((printer_id, filename))
+    return possible_keys
+
+
+def _archive_name_forms(archive) -> set[str]:
+    """The names an archive could be echoed back as, in normalised form (#3240).
+
+    Its print name, and its filename with and without the 3MF extension.
+    """
+    forms = set()
+    if archive.print_name:
+        forms.add(_normalise_subtask_name(archive.print_name))
+    if archive.filename:
+        forms.add(_normalise_subtask_name(archive.filename))
+        for suffix in (".gcode.3mf", ".3mf"):
+            if archive.filename.lower().endswith(suffix):
+                forms.add(_normalise_subtask_name(archive.filename[: -len(suffix)]))
+    forms.discard("")
+    return forms
+
+
+def _bound_print(printer_id: int, data: dict) -> tuple[int | None, int | None]:
+    """The archive and plate Bambuddy bound to the print ending on ``printer_id`` (#3240).
+
+    Read from ``_active_prints`` with the keys ``on_print_complete`` tries, and
+    from the plate recorded when the job was dispatched. Neither is touched:
+    ``on_print_complete`` pops both, which is why the finish-photo producer
+    calls this before its first await, while they are still there.
+
+    ``(None, None)`` for a print Bambuddy did not bind -- one it failed to
+    archive, or that it only knows by a name -- and on any error: the producer
+    calls this before the ``try`` that always releases the photo consumer, so
+    it must not raise.
+    """
+    try:
+        keys = _completion_print_keys(printer_id, str(data.get("filename") or ""), str(data.get("subtask_name") or ""))
+        archive_id = next((_active_prints[key] for key in keys if key in _active_prints), None)
+        if archive_id is None:
+            return None, None
+        return archive_id, _print_plate_ids.get(archive_id)
+    except Exception:
+        logging.getLogger(__name__).debug("Could not read the print binding for printer %s", printer_id, exc_info=True)
+        return None, None
+
+
+async def _max_z_for_current_print(
+    printer_id: int,
+    data: dict,
+    logger,
+    archive_id: int | None,
+    plate_id: int | None = None,
+) -> float | None:
     """Height of the print that just finished on ``printer_id``, or None (#2547).
     """Height of the print that just finished on ``printer_id``, or None (#2547).
 
 
     This number becomes the target of a real Z move, so every step here refuses
     This number becomes the target of a real Z move, so every step here refuses
@@ -6540,22 +6632,32 @@ async def _max_z_for_current_print(printer_id: int, data: dict, logger) -> float
     failure that could drive the nozzle into the model: 20 mm carried onto a
     failure that could drive the nozzle into the model: 20 mm carried onto a
     200 mm print would command the plate up through the part.
     200 mm print would command the plate up through the part.
 
 
-    Two independent things therefore have to agree before a height is returned:
-
-    1. **Identity.** The archive is matched by the finished print's own
-       ``subtask_name``, by equality rather than a ``LIKE``, so "Cube" can never
-       resolve to "Cube v2". Matching on "most recent archive for this printer"
-       is not good enough — ``on_print_complete`` pops the ``_active_prints``
-       binding concurrently with us, and a print Bambuddy failed to archive
-       would silently resolve to its predecessor.
-    2. **Corroboration.** The archive's layer count (parsed from the 3MF) has to
+    Three independent things therefore have to agree before a height is
+    returned:
+
+    1. **Binding.** ``archive_id`` is the archive Bambuddy bound to this print
+       when it started (see ``_bound_print``), never one found by name. A name
+       is not an identity: every plate of a multi-plate file shares one, and a
+       reprint reuses its archive row, so the newest archive with the right
+       name can be another plate of the same file. Support bundles show the
+       old name lookup doing exactly that (archive 288 for a print bound to
+       169, 313 layers against 125), stopped only by the layer check below.
+       No binding, no move.
+    2. **Identity.** The bound archive's name still has to be the finished
+       print's ``subtask_name``, compared in ``_normalise_subtask_name`` form
+       because the printer echoes spaces as underscores (#3240). Exact: not a
+       substring, and not the completion check's tolerance for a truncated
+       echo.
+    3. **Corroboration.** The archive's layer count (parsed from the 3MF) has to
        match the layer count the printer itself reported over MQTT for the print
        match the layer count the printer itself reported over MQTT for the print
        that just ended. These come from genuinely different sources, so a
        that just ended. These come from genuinely different sources, so a
        mismatch means the row is not this print, whatever its name says.
        mismatch means the row is not this print, whatever its name says.
 
 
-    ``completed`` is accepted alongside ``printing`` only because
+    ``plate_id`` is the plate the job was dispatched with; a reprint of one
+    plate of a multi-plate file reuses the archive row without updating its
+    ``plate_id``. ``completed`` is accepted alongside ``printing`` only because
     ``on_print_complete`` may already have flipped the status by the time we
     ``on_print_complete`` may already have flipped the status by the time we
-    run; the identity check above is what actually selects the row.
+    run.
     """
     """
     subtask_name = (data.get("subtask_name") or "").strip()
     subtask_name = (data.get("subtask_name") or "").strip()
     if not subtask_name:
     if not subtask_name:
@@ -6563,6 +6665,13 @@ async def _max_z_for_current_print(printer_id: int, data: dict, logger) -> float
         # "whatever ran last on this printer".
         # "whatever ran last on this printer".
         logger.info("[PLATE-RESTORE] printer %s: print has no name to match on — skipping", printer_id)
         logger.info("[PLATE-RESTORE] printer %s: print has no name to match on — skipping", printer_id)
         return None
         return None
+    if archive_id is None:
+        logger.info(
+            "[PLATE-RESTORE] printer %s: %r is not bound to an archive — skipping",
+            printer_id,
+            subtask_name,
+        )
+        return None
 
 
     try:
     try:
         from backend.app.models.archive import PrintArchive
         from backend.app.models.archive import PrintArchive
@@ -6570,24 +6679,24 @@ async def _max_z_for_current_print(printer_id: int, data: dict, logger) -> float
 
 
         async with async_session() as db:
         async with async_session() as db:
             result = await db.execute(
             result = await db.execute(
-                select(PrintArchive)
-                .where(
+                select(PrintArchive).where(
+                    PrintArchive.id == archive_id,
                     PrintArchive.printer_id == printer_id,
                     PrintArchive.printer_id == printer_id,
                     PrintArchive.status.in_(("printing", "completed")),
                     PrintArchive.status.in_(("printing", "completed")),
                     PrintArchive.deleted_at.is_(None),
                     PrintArchive.deleted_at.is_(None),
-                    or_(
-                        PrintArchive.print_name == subtask_name,
-                        PrintArchive.filename == subtask_name,
-                        PrintArchive.filename == f"{subtask_name}.3mf",
-                        PrintArchive.filename == f"{subtask_name}.gcode.3mf",
-                    ),
                 )
                 )
-                .order_by(PrintArchive.id.desc())
-                .limit(1)
             )
             )
             archive = result.scalar_one_or_none()
             archive = result.scalar_one_or_none()
         if archive is None or not archive.file_path:
         if archive is None or not archive.file_path:
-            logger.info("[PLATE-RESTORE] printer %s: no archive matches %r — skipping", printer_id, subtask_name)
+            logger.info("[PLATE-RESTORE] printer %s: archive %s has no usable file — skipping", printer_id, archive_id)
+            return None
+        if _normalise_subtask_name(subtask_name) not in _archive_name_forms(archive):
+            logger.warning(
+                "[PLATE-RESTORE] printer %s: archive %s is not named %r — refusing to move the plate",
+                printer_id,
+                archive_id,
+                subtask_name,
+            )
             return None
             return None
 
 
         client = printer_manager.get_client(printer_id)
         client = printer_manager.get_client(printer_id)
@@ -6603,10 +6712,11 @@ async def _max_z_for_current_print(printer_id: int, data: dict, logger) -> float
             )
             )
             return None
             return None
 
 
-        path = Path(archive.file_path)
-        if not path.is_absolute():
-            path = Path(app_settings.data_dir) / path
-        return await asyncio.to_thread(extract_max_z_height_from_3mf, path, archive.plate_id or 1)
+        # Stored relative to the data directory, like every other archive path
+        # (#3240: this read a `data_dir` setting that does not exist).
+        path = app_settings.base_dir / archive.file_path
+        plate = plate_id or archive.plate_id or 1
+        return await asyncio.to_thread(extract_max_z_height_from_3mf, path, plate)
     except Exception as e:
     except Exception as e:
         logger.debug("[PLATE-RESTORE] printer %s: no usable print height: %s", printer_id, e)
         logger.debug("[PLATE-RESTORE] printer %s: no usable print height: %s", printer_id, e)
         return None
         return None
@@ -6755,6 +6865,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
     producer_done = asyncio.Event()
     producer_done = asyncio.Event()
     _stage22_finish_in_flight[printer_id] = producer_done
     _stage22_finish_in_flight[printer_id] = producer_done
 
 
+    # #3240: also before the first await. On the FINISH-state path
+    # `on_print_complete` is dispatched right behind us and pops both the
+    # print's archive binding and its plate; read here, they are still there.
+    bound_archive_id, bound_plate_id = _bound_print(printer_id, data)
+
     # #2547: set once the plate has actually been raised, and read by the
     # #2547: set once the plate has actually been raised, and read by the
     # `finally` below. Declared out here so a failure anywhere after the move —
     # `finally` below. Declared out here so a failure anywhere after the move —
     # a camera timeout, a DB error — still lowers the plate again.
     # a camera timeout, a DB error — still lowers the plate again.
@@ -6834,7 +6949,7 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
             and restore_plate_enabled
             and restore_plate_enabled
             and not print_dispatch_context.end_gcode_injected(printer_id)
             and not print_dispatch_context.end_gcode_injected(printer_id)
         ):
         ):
-            wants_restore = await _max_z_for_current_print(printer_id, data, logger)
+            wants_restore = await _max_z_for_current_print(printer_id, data, logger, bound_archive_id, bound_plate_id)
             if wants_restore is None:
             if wants_restore is None:
                 logger.info(
                 logger.info(
                     "[PLATE-RESTORE] printer %s: print height unknown — capturing without restore",
                     "[PLATE-RESTORE] printer %s: print height unknown — capturing without restore",
@@ -7289,41 +7404,7 @@ async def on_print_complete(printer_id: int, data: dict):
     logger.info("Print complete - filename: %s, subtask: %s, status: %s", filename, subtask_name, data.get("status"))
     logger.info("Print complete - filename: %s, subtask: %s, status: %s", filename, subtask_name, data.get("status"))
 
 
     # Build list of possible keys to try (matching how they were registered in on_print_start)
     # Build list of possible keys to try (matching how they were registered in on_print_start)
-    possible_keys = []
-
-    # Try subtask_name variations first (most reliable for matching)
-    if subtask_name:
-        possible_keys.append((printer_id, f"{subtask_name}.3mf"))
-        possible_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
-        possible_keys.append((printer_id, subtask_name))
-
-    # Try filename variations
-    if filename:
-        # Extract just the filename if it's a path
-        fname = filename.split("/")[-1] if "/" in filename else filename
-
-        if fname.endswith(".3mf"):
-            possible_keys.append((printer_id, fname))
-        elif fname.endswith(".gcode"):
-            base_name = fname.rsplit(".", 1)[0]
-            possible_keys.append((printer_id, f"{base_name}.gcode.3mf"))
-            possible_keys.append((printer_id, f"{base_name}.3mf"))
-            possible_keys.append((printer_id, fname))
-        else:
-            possible_keys.append((printer_id, f"{fname}.gcode.3mf"))
-            possible_keys.append((printer_id, f"{fname}.3mf"))
-            possible_keys.append((printer_id, fname))
-
-        # Also try full path versions
-        if filename.endswith(".3mf"):
-            possible_keys.append((printer_id, filename))
-        elif filename.endswith(".gcode"):
-            base_name = filename.rsplit(".", 1)[0]
-            possible_keys.append((printer_id, f"{base_name}.3mf"))
-            possible_keys.append((printer_id, filename))
-        else:
-            possible_keys.append((printer_id, f"{filename}.3mf"))
-            possible_keys.append((printer_id, filename))
+    possible_keys = _completion_print_keys(printer_id, filename, subtask_name)
 
 
     # Find the archive for this print
     # Find the archive for this print
     logger.info("Looking for archive in _active_prints, keys to try: %s...", possible_keys[:5])
     logger.info("Looking for archive in _active_prints, keys to try: %s...", possible_keys[:5])
@@ -7338,6 +7419,10 @@ async def on_print_complete(printer_id: int, data: dict):
             for k in keys_to_remove:
             for k in keys_to_remove:
                 _active_prints.pop(k, None)
                 _active_prints.pop(k, None)
             break
             break
+    # The archive bound when the print started, before the name search below.
+    # Only this one may give the finish-photo plate restore a height (#3240):
+    # the search matches names by substring.
+    bound_archive_id = archive_id
 
 
     if not archive_id:
     if not archive_id:
         # Try to find by filename or subtask_name if not tracked (for prints started before app)
         # Try to find by filename or subtask_name if not tracked (for prints started before app)
@@ -8253,7 +8338,9 @@ async def on_print_complete(printer_id: int, data: dict):
 
 
                         restore_setting = await get_setting(db, "finish_photo_restore_plate")
                         restore_setting = await get_setting(db, "finish_photo_restore_plate")
                     if restore_setting is None or restore_setting.lower() == "true":
                     if restore_setting is None or restore_setting.lower() == "true":
-                        max_z = await _max_z_for_current_print(printer_id, data, logger)
+                        max_z = await _max_z_for_current_print(
+                            printer_id, data, logger, bound_archive_id, notify_plate_id
+                        )
                         if max_z is not None and not await _plate_restore_is_blocked_by_queue(printer_id):
                         if max_z is not None and not await _plate_restore_is_blocked_by_queue(printer_id):
                             if await _restore_plate_for_finish_photo(printer_id, max_z, logger):
                             if await _restore_plate_for_finish_photo(printer_id, max_z, logger):
                                 plate_restored_z = max_z
                                 plate_restored_z = max_z

+ 228 - 69
backend/tests/unit/test_finish_photo_moment_sync.py

@@ -17,13 +17,18 @@ by the consumer. These tests pin the producer side of that contract.
 import asyncio
 import asyncio
 import logging
 import logging
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
+from datetime import datetime, timezone
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import AsyncMock
 from unittest.mock import AsyncMock
 
 
 import pytest
 import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
 
 
+import backend.app.models  # noqa: F401 - populate Base.metadata
 from backend.app import main as main_module
 from backend.app import main as main_module
+from backend.app.core.database import Base
 from backend.app.main import on_finish_photo_moment
 from backend.app.main import on_finish_photo_moment
+from backend.app.models.archive import PrintArchive
 from backend.app.services import print_dispatch_context
 from backend.app.services import print_dispatch_context
 
 
 
 
@@ -86,7 +91,7 @@ def patched_env(fake_printer, monkeypatch):
     # #2547: default the plate restore to "print height unknown", so tests that
     # #2547: default the plate restore to "print height unknown", so tests that
     # aren't about the restore never reach the G-code path. Tests that ARE about
     # aren't about the restore never reach the G-code path. Tests that ARE about
     # it override these two.
     # it override these two.
-    async def _no_height(_printer_id, _data, _logger):
+    async def _no_height(_printer_id, _data, _logger, _archive_id, _plate_id=None):
         return None
         return None
 
 
     async def _not_blocked(_printer_id):
     async def _not_blocked(_printer_id):
@@ -614,9 +619,10 @@ class TestPlateRestoreWiring:
 
 
     @pytest.fixture
     @pytest.fixture
     def restore_env(self, patched_env, monkeypatch):
     def restore_env(self, patched_env, monkeypatch):
-        calls = {"restore": [], "park": [], "blocked": False, "height": 16.0}
+        calls = {"restore": [], "park": [], "blocked": False, "height": 16.0, "bound": []}
 
 
-        async def _height(_printer_id, _data, _logger):
+        async def _height(_printer_id, _data, _logger, archive_id, plate_id=None):
+            calls["bound"].append((archive_id, plate_id))
             return calls["height"]
             return calls["height"]
 
 
         async def _blocked(_printer_id):
         async def _blocked(_printer_id):
@@ -646,6 +652,37 @@ class TestPlateRestoreWiring:
         assert restore_env["restore"] == [(patched_env.id, 16.0)]
         assert restore_env["restore"] == [(patched_env.id, 16.0)]
         assert restore_env["park"] == [(patched_env.id, 16.0)]
         assert restore_env["park"] == [(patched_env.id, 16.0)]
 
 
+    async def test_reads_the_binding_before_on_print_complete_pops_it(self, patched_env, restore_env, monkeypatch):
+        """#3240: on the FINISH-state path on_print_complete is dispatched right
+        behind the producer and pops the print's archive binding and plate. The
+        producer must have read them first, or the restore has nothing bound to
+        move for and never runs."""
+
+        async def _yielding_get_setting(_db, key):
+            # A real settings read suspends; this is where on_print_complete
+            # gets to run.
+            await asyncio.sleep(0)
+            return "true" if key == "capture_finish_photo" else None
+
+        monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _yielding_get_setting)
+        main_module._active_prints[(patched_env.id, "job.3mf")] = 5
+        main_module._print_plate_ids[5] = 3
+
+        async def _on_print_complete_pops():
+            main_module._active_prints.clear()
+            main_module._print_plate_ids.clear()
+
+        try:
+            data = {"trigger": "finish_state", "filename": "job.3mf", "subtask_name": "job"}
+            producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, data))
+            completion = asyncio.create_task(_on_print_complete_pops())
+            await asyncio.gather(producer, completion)
+        finally:
+            main_module._active_prints.clear()
+            main_module._print_plate_ids.clear()
+
+        assert restore_env["bound"] == [(5, 3)]
+
     async def test_not_restored_on_the_stage_22_path(self, patched_env, restore_env):
     async def test_not_restored_on_the_stage_22_path(self, patched_env, restore_env):
         """Stage 22 fires before the end G-code drops the plate — it is already
         """Stage 22 fires before the end G-code drops the plate — it is already
         where we want it, and moving it would only cost the settle delay."""
         where we want it, and moving it would only cost the settle delay."""
@@ -758,102 +795,224 @@ class TestMaxZResolution:
     nozzle into the model — 20mm carried onto a 200mm print commands the plate
     nozzle into the model — 20mm carried onto a 200mm print commands the plate
     up through the part. So the resolver refuses on every ambiguity rather than
     up through the part. So the resolver refuses on every ambiguity rather than
     falling back to "whatever ran last on this printer".
     falling back to "whatever ran last on this printer".
-    """
 
 
-    @staticmethod
-    def _archive(**overrides):
-        base = {
-            "id": 11,
-            "file_path": "/data/archive/1/job/job.3mf",
-            "plate_id": 1,
-            "total_layers": 30,
-        }
-        base.update(overrides)
-        return SimpleNamespace(**base)
+    Run against a real database (#3240): the earlier tests faked the session,
+    so neither the query nor the path the archive's file is read from was ever
+    exercised, and both were wrong.
+    """
 
 
     @pytest.fixture
     @pytest.fixture
-    def resolver_env(self, monkeypatch):
-        env = {"archive": self._archive(), "reported_layers": 30, "height": 16.0, "where": None}
-
-        @asynccontextmanager
-        async def _session():
-            async def _execute(stmt):
-                env["where"] = str(stmt)
-                return SimpleNamespace(scalar_one_or_none=lambda: env["archive"])
-
-            yield SimpleNamespace(execute=_execute)
-
-        monkeypatch.setattr(main_module, "async_session", _session)
+    async def resolver_env(self, monkeypatch, tmp_path):
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        session_maker = async_sessionmaker(engine, expire_on_commit=False)
+        env = {"reported_layers": 30, "height": 16.0, "read": [], "session_maker": session_maker}
+
+        monkeypatch.setattr(main_module, "async_session", session_maker)
+        monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
         monkeypatch.setattr(
         monkeypatch.setattr(
             main_module.printer_manager,
             main_module.printer_manager,
             "get_client",
             "get_client",
             lambda _pid: SimpleNamespace(state=SimpleNamespace(total_layers=env["reported_layers"])),
             lambda _pid: SimpleNamespace(state=SimpleNamespace(total_layers=env["reported_layers"])),
         )
         )
-        monkeypatch.setattr(
-            "backend.app.utils.threemf_tools.extract_max_z_height_from_3mf",
-            lambda _path, _plate: env["height"],
-        )
-        return env
 
 
-    async def test_returns_the_height_when_name_and_layers_agree(self, resolver_env):
-        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
-        assert height == 16.0
+        def _extract(path, plate):
+            env["read"].append((path, plate))
+            return env["height"]
 
 
-    async def test_refuses_when_the_print_has_no_name_to_match_on(self, resolver_env):
-        """Without an identifier there is nothing to bind the archive to, and
-        the query would degrade to 'the newest row for this printer'."""
-        height = await main_module._max_z_for_current_print(1, {}, logging.getLogger(__name__))
+        monkeypatch.setattr("backend.app.utils.threemf_tools.extract_max_z_height_from_3mf", _extract)
+        try:
+            yield env
+        finally:
+            await engine.dispose()
 
 
-        assert height is None
-        assert resolver_env["where"] is None  # refused before touching the DB
+    @staticmethod
+    async def _archive(env, **overrides) -> int:
+        values = {
+            "printer_id": 1,
+            "filename": "job.gcode.3mf",
+            "file_path": "archive/1/job/job.gcode.3mf",
+            "file_size": 1,
+            "print_name": "job",
+            "status": "completed",
+            "plate_id": 1,
+            "total_layers": 30,
+        }
+        values.update(overrides)
+        async with env["session_maker"]() as db:
+            archive = PrintArchive(**values)
+            db.add(archive)
+            await db.commit()
+            return archive.id
 
 
-    async def test_refuses_when_no_archive_matches_the_name(self, resolver_env):
-        resolver_env["archive"] = None
+    @staticmethod
+    async def _resolve(name, archive_id, plate_id=None):
+        data = {"subtask_name": name} if name is not None else {}
+        return await main_module._max_z_for_current_print(1, data, logging.getLogger(__name__), archive_id, plate_id)
+
+    async def test_returns_the_height_when_binding_name_and_layers_agree(self, resolver_env):
+        archive_id = await self._archive(resolver_env)
+        assert await self._resolve("job", archive_id) == 16.0
+
+    async def test_refuses_a_print_that_is_not_bound_to_an_archive(self, resolver_env):
+        """A name alone is never enough: with no binding there is no move, even
+        when an archive of exactly that name exists."""
+        await self._archive(resolver_env)
+        assert await self._resolve("job", None) is None
+        assert resolver_env["read"] == []
+
+    async def test_uses_the_bound_archive_not_a_newer_one_of_the_same_name(self, resolver_env):
+        """The support-bundle case: a reprint reuses archive 169, while a newer
+        archive of the same file (another plate) is 288. The name lookup took
+        288; only the layer check stopped it."""
+        bound = await self._archive(resolver_env, file_path="archive/reprinted.gcode.3mf", total_layers=125)
+        await self._archive(resolver_env, file_path="archive/other-plate.gcode.3mf", total_layers=313)
+        resolver_env["reported_layers"] = 125
+
+        assert await self._resolve("job", bound) == 16.0
+        assert resolver_env["read"][0][0].name == "reprinted.gcode.3mf"
+
+    async def test_reads_the_plate_the_job_was_dispatched_with(self, resolver_env):
+        """A reprint of one plate reuses the archive row without updating its
+        plate_id, so the dispatched plate wins."""
+        archive_id = await self._archive(resolver_env, plate_id=1)
+
+        await self._resolve("job", archive_id, plate_id=12)
+        assert resolver_env["read"][0][1] == 12
+
+        await self._resolve("job", archive_id)
+        assert resolver_env["read"][1][1] == 1
+
+    async def test_reads_a_relative_file_path_from_the_data_directory(self, resolver_env, tmp_path):
+        """#3240: archive paths are stored relative to the data directory. The
+        resolver joined them to a `data_dir` setting that does not exist, so the
+        lookup raised, was logged at debug level, and the plate never moved."""
+        archive_id = await self._archive(resolver_env, file_path="archive/1/job/job.gcode.3mf", plate_id=2)
+
+        assert await self._resolve("job", archive_id) == 16.0
+        assert resolver_env["read"] == [(tmp_path / "archive/1/job/job.gcode.3mf", 2)]
+
+    async def test_an_absolute_file_path_is_read_as_stored(self, resolver_env):
+        archive_id = await self._archive(resolver_env, file_path="/srv/bambuddy/archive/job.gcode.3mf")
+
+        assert await self._resolve("job", archive_id) == 16.0
+        assert str(resolver_env["read"][0][0]) == "/srv/bambuddy/archive/job.gcode.3mf"
+
+    async def test_matches_a_name_the_printer_echoed_with_underscores(self, resolver_env):
+        """#3240: the printer reports "Cube v2" as "Cube_v2"."""
+        archive_id = await self._archive(resolver_env, print_name="Cube v2", filename="Cube v2.gcode.3mf")
+        assert await self._resolve("Cube_v2", archive_id) == 16.0
+
+    async def test_matches_on_the_filename_with_or_without_its_extension(self, resolver_env):
+        archive_id = await self._archive(resolver_env, print_name=None, filename="Bracket Left.gcode.3mf")
+        assert await self._resolve("Bracket_Left", archive_id) == 16.0
+        assert await self._resolve("Bracket_Left.gcode", archive_id) == 16.0
+        assert await self._resolve("Bracket Left.gcode.3mf", archive_id) == 16.0
+
+    async def test_refuses_a_bound_archive_with_another_name(self, resolver_env):
+        archive_id = await self._archive(resolver_env)
+        assert await self._resolve("other", archive_id) is None
 
 
-        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
-        assert height is None
+    async def test_matches_by_equality_not_substring(self, resolver_env):
+        """ "Cube" must never resolve to "Cube v2" — a different print, quite
+        possibly a much taller one."""
+        archive_id = await self._archive(resolver_env, print_name="Cube v2", filename="Cube v2.gcode.3mf")
+        assert await self._resolve("Cube", archive_id) is None
+
+    async def test_a_truncated_echo_does_not_match(self, resolver_env):
+        """The completion check accepts the printer's "..." truncation; a Z
+        move must not, since the cut-off name could belong to another print."""
+        archive_id = await self._archive(resolver_env, print_name="A very long print name indeed")
+        assert await self._resolve("A_very_long_print...", archive_id) is None
+
+    async def test_refuses_another_printers_or_a_failed_or_deleted_archive(self, resolver_env):
+        for overrides in ({"printer_id": 2}, {"status": "failed"}, {"deleted_at": datetime.now(timezone.utc)}):
+            archive_id = await self._archive(resolver_env, **overrides)
+            assert await self._resolve("job", archive_id) is None, overrides
+
+    async def test_refuses_when_the_print_has_no_name_to_match_on(self, resolver_env):
+        archive_id = await self._archive(resolver_env)
+        assert await self._resolve(None, archive_id) is None
+        assert resolver_env["read"] == []
 
 
     async def test_refuses_when_the_layer_counts_disagree(self, resolver_env):
     async def test_refuses_when_the_layer_counts_disagree(self, resolver_env):
         """The corroboration check. The archive's layer count comes from the
         """The corroboration check. The archive's layer count comes from the
         3MF; the printer's comes from MQTT. If two independent sources disagree,
         3MF; the printer's comes from MQTT. If two independent sources disagree,
         the row is not this print whatever its name says."""
         the row is not this print whatever its name says."""
+        archive_id = await self._archive(resolver_env)
         resolver_env["reported_layers"] = 240
         resolver_env["reported_layers"] = 240
-
-        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
-        assert height is None
+        assert await self._resolve("job", archive_id) is None
 
 
     async def test_proceeds_when_a_layer_count_is_simply_unknown(self, resolver_env):
     async def test_proceeds_when_a_layer_count_is_simply_unknown(self, resolver_env):
         """Absent is not the same as contradictory — a print Bambuddy has no
         """Absent is not the same as contradictory — a print Bambuddy has no
-        layer count for still gets its height, because the name matched."""
+        layer count for still gets its height, because binding and name agree."""
+        archive_id = await self._archive(resolver_env)
         resolver_env["reported_layers"] = 0
         resolver_env["reported_layers"] = 0
-        assert (
-            await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) == 16.0
-        )
+        assert await self._resolve("job", archive_id) == 16.0
 
 
         resolver_env["reported_layers"] = 30
         resolver_env["reported_layers"] = 30
-        resolver_env["archive"] = self._archive(total_layers=None)
-        assert (
-            await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) == 16.0
-        )
-
-    async def test_matches_by_equality_not_substring(self, resolver_env):
-        """`LIKE %name%` would let "Cube" resolve to "Cube v2" — a different
-        print, quite possibly a much taller one."""
-        await main_module._max_z_for_current_print(1, {"subtask_name": "Cube"}, logging.getLogger(__name__))
-
-        assert "LIKE" not in resolver_env["where"].upper()
+        unknown_layers = await self._archive(resolver_env, total_layers=None)
+        assert await self._resolve("job", unknown_layers) == 16.0
 
 
     async def test_refuses_when_the_archive_has_no_file(self, resolver_env):
     async def test_refuses_when_the_archive_has_no_file(self, resolver_env):
-        resolver_env["archive"] = self._archive(file_path=None)
-
-        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
-        assert height is None
+        archive_id = await self._archive(resolver_env, file_path="")
+        assert await self._resolve("job", archive_id) is None
 
 
     async def test_refuses_when_the_3mf_has_no_height(self, resolver_env):
     async def test_refuses_when_the_3mf_has_no_height(self, resolver_env):
+        archive_id = await self._archive(resolver_env)
         resolver_env["height"] = None
         resolver_env["height"] = None
+        assert await self._resolve("job", archive_id) is None
+
+
+class TestBoundPrint:
+    """The binding the plate restore reads (#3240)."""
+
+    @pytest.fixture(autouse=True)
+    def _clean(self):
+        main_module._active_prints.clear()
+        main_module._print_plate_ids.clear()
+        yield
+        main_module._active_prints.clear()
+        main_module._print_plate_ids.clear()
+
+    def test_finds_the_archive_under_the_keys_on_print_complete_tries(self):
+        main_module._active_prints[(7, "Cube v2.gcode.3mf")] = 169
+        main_module._active_prints[(7, "Cube_v2.3mf")] = 169
+        main_module._print_plate_ids[169] = 12
+
+        bound = main_module._bound_print(7, {"filename": "Cube_v2.3mf", "subtask_name": "Cube_v2"})
+
+        assert bound == (169, 12)
+
+    def test_reads_without_consuming(self):
+        """on_print_complete still has to find the binding after we looked."""
+        main_module._active_prints[(7, "job.3mf")] = 5
+        main_module._print_plate_ids[5] = 2
+
+        main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"})
+
+        assert main_module._active_prints == {(7, "job.3mf"): 5}
+        assert main_module._print_plate_ids == {5: 2}
+
+    def test_unbound_print_and_other_printers(self):
+        main_module._active_prints[(8, "job.3mf")] = 5
+
+        assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"}) == (None, None)
+        assert main_module._bound_print(7, {}) == (None, None)
+
+    def test_no_plate_recorded(self):
+        main_module._active_prints[(7, "job.3mf")] = 5
+        assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"}) == (5, None)
+
+    def test_same_archive_as_on_print_complete_takes(self):
+        """Keys tried in the same order, so the first one present wins in both."""
+        main_module._active_prints[(7, "job.gcode.3mf")] = 1
+        main_module._active_prints[(7, "job.3mf")] = 2
+        keys = main_module._completion_print_keys(7, "job.3mf", "job")
+        first = next(main_module._active_prints[k] for k in keys if k in main_module._active_prints)
 
 
-        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
-        assert height is None
+        assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"})[0] == first == 2
 
 
 
 
 class TestTimelapsePathPlateRestore:
 class TestTimelapsePathPlateRestore:
@@ -885,7 +1044,7 @@ class TestTimelapsePathPlateRestore:
             moved.append((printer_id, max_z))
             moved.append((printer_id, max_z))
             return True
             return True
 
 
-        async def _height(_printer_id, _data, _logger):
+        async def _height(_printer_id, _data, _logger, _archive_id, _plate_id=None):
             return 16.0
             return 16.0
 
 
         monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
         monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)