Selaa lähdekoodia

fix(printers): LAN print is linked to its archive (issue #3327)

- A print started from Bambu Studio over LAN, Handy or the printer's
  screen reports subtask_id "0", stored as no id, and the running
  print's archive was found only by subtask_id: status kept
  current_archive_id null for the whole print, the card could not
  name the plate, and the cover and skip-objects fetched the 3MF from
  the printer again.
- New main.running_print_archive: by subtask_id, else the archive
  bound to the running print, else for a "0" print the newest printing
  archive without a job id under the reported name (after a restart,
  which rebinds only Bambuddy's own prints). Never a deleted archive.
  Status, the cover, the skip-objects reload and the restart object
  restore use it; skip-objects never by name, as an earlier run of
  the same name left printing would offer its objects to skip. The
  name lookup is shared with the kill switch's "0" branch, unchanged.
- archive.started_at was the time the archive was written, after the
  3MF download, so the print time taken from it at completion was
  short by up to a minute. on_print_start now passes the time it saw
  the print start, also to the no-3MF fallback archive.
- Baseline entry moved with a re-indented line. Tests for the LAN,
  cloud, restart, deleted-archive, skip-objects and start-time cases.
maziggy 1 päivä sitten
vanhempi
sitoutus
bb7098de9f

+ 2 - 2
.basedpyright/baseline.json

@@ -1026,8 +1026,8 @@
             {
                 "code": "reportAttributeAccessIssue",
                 "range": {
-                    "startColumn": 33,
-                    "endColumn": 59,
+                    "startColumn": 29,
+                    "endColumn": 55,
                     "lineCount": 1
                 }
             },

+ 1 - 0
CHANGELOG.md

@@ -174,6 +174,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.
 
 ### Fixed
+- **A print started over LAN is linked to its archive, and its print time is no longer short (#3327, reported by @Andrewdlec)** — A print sent from Bambu Studio over LAN, or started from Handy or the printer's screen, reports no job id, and printer status found a running print's archive only by that id, so `current_archive_id` stayed empty for the whole print and the printer card could not name the plate of a multi-plate file. Status now also finds the running print's archive as soon as it exists, also after a Bambuddy restart mid-print, and never names an archive that was deleted. The printer card's cover image and a reloaded skip-objects list of such a print are now read from the archived 3MF instead of being fetched from the busy printer again over FTPS (skip-objects only while Bambuddy knows which archive is the print's, never by name). Separately, an archive's `started_at` was the moment it was written, after the 3MF download, which can take a minute at the start of a print; the print time taken from it at completion was short by that much. It is now the moment the print was seen to start, also for an archive written without a 3MF.
 - **A spool replaced during a print is charged only for its part (#3325, reported by @HarryCoff2)** — When a spool ran out and a new one was loaded in the same slot (an external spool, or an AMS slot without a backup) and assigned in Bambuddy while the print ran, the whole print was charged to the new spool. Bambuddy now notes the layer of the new assignment and, when the print ends, splits the filament between the two spools by what the G-code feeds before and after it, for completed and failed prints, with the built-in inventory and with Spoolman, also after a restart mid-print. A slot that had no spool when the print started is still charged to the spool assigned to it, for the whole print.
 - **A write is saved before its response is sent (#3320, reported by @MorganMLGman)** — Many API routes left the final save to the end of the request, and that ran only after the response had gone out. A client reading straight after a successful write could miss it (a new project came back as 404, an edit still showed the old value), and a save that failed at that point was undone behind a success the client already had. Every route now saves before it answers, and a failed save is reported as an error.
 - **A failed or cancelled print's card says its cost is for one full print (#3318, reported by @ojimpo)** — After a print failed or was cancelled, the archive card showed the cost and grams of the whole file with no hint, as if that were what the run cost. The archive keeps one full print's figures on purpose (reprints are estimated and billed from them); the cost and grams now carry a tooltip saying so, pointing to the archive's Print Log for what the run used. With Spoolman, that run's cost is priced from the spools it was charged to.

+ 36 - 60
backend/app/api/routes/printers.py

@@ -762,22 +762,17 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
     # Resolve the active print's archive + plate (#881 follow-up): lets the
     # printer card show the actual plate name for multi-plate 3MFs instead of
     # just the 3MF filename. Only attempted for active prints, since subtask_id
-    # is only meaningful then.
+    # is only meaningful then. A print started outside the cloud reports
+    # subtask_id "0" and is linked by its running archive instead (#3327).
+    # A deleted archive is never named.
     current_archive_id: int | None = None
     current_plate_id: int | None = None
     if state.state in ("RUNNING", "PAUSE"):
         current_plate_id = resolve_plate_id(state)
-        if state.subtask_id:
-            from backend.app.models.archive import PrintArchive
-
-            archive_row = await db.execute(
-                select(PrintArchive.id)
-                .where(PrintArchive.subtask_id == state.subtask_id)
-                .where(PrintArchive.printer_id == printer_id)
-                .order_by(PrintArchive.created_at.desc())
-                .limit(1)
-            )
-            current_archive_id = archive_row.scalar_one_or_none()
+        from backend.app.main import running_print_archive
+
+        archive = await running_print_archive(db, printer_id, state, printing_only=False, by_name=True)
+        current_archive_id = archive.id if archive is not None else None
 
     return PrinterStatus(
         id=printer_id,
@@ -1116,31 +1111,21 @@ def clear_cover_cache(printer_id: int) -> None:
 async def _running_print_archive_file(printer_id: int, state) -> Path | None:
     """Path to the 3MF of the print this printer is running, if we have it.
 
-    Bambuddy archives the sliced file when the print starts, so the copy the
-    printer is executing is usually already on disk. Anchored on ``subtask_id``,
-    which the firmware mints per print: a leftover ``status="printing"`` row from
-    a completion that was never seen must not lend its file to another job.
+        Bambuddy archives the sliced file when the print starts, so the copy the
+        printer is executing is usually already on disk. Found as
+        ``running_print_archive`` finds it: by ``subtask_id``, which the firmware
+        mints per print, so a leftover ``status="printing"`` row from a completion
+        that was never seen must not lend its file to another job, and for a print
+        that reports none by its binding or name (#3327); a wrong guess there costs
+    only the picture.
 
-    Opens its own short-lived session, like the caller does, so the pooled
-    connection is not held across the FTP work that follows.
+        Opens its own short-lived session, like the caller does, so the pooled
+        connection is not held across the FTP work that follows.
     """
-    subtask_id = str(getattr(state, "subtask_id", "") or "").strip()
-    if subtask_id in ("", "0"):
-        return None
-
-    from backend.app.models.archive import PrintArchive
+    from backend.app.main import running_print_archive
 
     async with database.async_session() as db:
-        archive = await db.scalar(
-            select(PrintArchive)
-            .where(
-                PrintArchive.printer_id == printer_id,
-                PrintArchive.status == "printing",
-                PrintArchive.subtask_id == subtask_id,
-            )
-            .order_by(PrintArchive.created_at.desc())
-            .limit(1)
-        )
+        archive = await running_print_archive(db, printer_id, state, printing_only=True, by_name=True)
     if archive is None or not archive.file_path:
         return None
 
@@ -4190,36 +4175,27 @@ async def get_printable_objects(
         # all. skipped_objects is deliberately left alone: a reload is
         # not a new print, and the list of what the user already skipped only
         # lives here.
-        from backend.app.models.archive import PrintArchive
+        from backend.app.main import running_print_archive
         from backend.app.services.archive import extract_printable_objects_from_archive
 
-        subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip()
-        if subtask_id not in ("", "0"):
-            async with database.async_session() as db:
-                archive = await db.scalar(
-                    select(PrintArchive)
-                    .where(
-                        PrintArchive.printer_id == printer_id,
-                        PrintArchive.status == "printing",
-                        PrintArchive.subtask_id == subtask_id,
-                    )
-                    .order_by(PrintArchive.created_at.desc())
-                    .limit(1)
-                )
-            if archive is not None:
-                objects, bbox_all = extract_printable_objects_from_archive(
-                    settings.base_dir / archive.file_path,
-                    plate_number=resolve_plate_id(client.state),
+        # Never by name: an earlier run of the same name left printing would
+        # offer its objects to skip on this one (#3327).
+        async with database.async_session() as db:
+            archive = await running_print_archive(db, printer_id, client.state, printing_only=True, by_name=False)
+        if archive is not None and archive.file_path:
+            objects, bbox_all = extract_printable_objects_from_archive(
+                settings.base_dir / archive.file_path,
+                plate_number=resolve_plate_id(client.state),
+            )
+            if objects:
+                client.state.printable_objects = objects
+                client.state.printable_objects_bbox_all = bbox_all
+                logger.info(
+                    "Reloaded %s objects for printer %s from archive %s",
+                    len(objects),
+                    printer_id,
+                    archive.id,
                 )
-                if objects:
-                    client.state.printable_objects = objects
-                    client.state.printable_objects_bbox_all = bbox_all
-                    logger.info(
-                        "Reloaded %s objects for printer %s from archive %s",
-                        len(objects),
-                        printer_id,
-                        archive.id,
-                    )
 
     # Only when the disk could not answer: a `reload=true` that the archive
     # satisfied has already refreshed from the file the printer is running.

+ 92 - 38
backend/app/main.py

@@ -858,6 +858,82 @@ def _is_bambuddy_authorized_print_in_memory(printer_id: int, state: PrinterState
     )
 
 
+def _reported_print_names(printer_id: int, state: PrinterState) -> set[str]:
+    """The names the printer reports for its print, as it echoes them (spaces become underscores)."""
+    return {_normalise_subtask_name(name) for _pid, name in _build_status_print_keys(printer_id, state)} - {""}
+
+
+async def _printing_archive_without_job_id(
+    db, printer_id: int, reported: set[str], *, dispatched_only: bool, live_only: bool = False
+):
+    """The newest printing archive on ``printer_id`` of a print that reported no job id, under a reported name.
+
+    An archive with a real id of its own is a print that reported one: a "0"
+    print is not it, even under the same name (one whose completion was missed
+    would otherwise cover it). ``dispatched_only`` keeps to archives carrying
+    Bambuddy's dispatch marker; ``live_only`` leaves out deleted ones.
+    """
+    from backend.app.models.archive import PrintArchive
+
+    query = select(PrintArchive).where(
+        PrintArchive.printer_id == printer_id,
+        PrintArchive.status == "printing",
+        or_(PrintArchive.subtask_id.is_(None), PrintArchive.subtask_id.in_(("", "0"))),
+    )
+    if dispatched_only:
+        query = query.where(or_(PrintArchive.billing_run_id.is_not(None), PrintArchive.created_by_id.is_not(None)))
+    if live_only:
+        query = query.where(PrintArchive.deleted_at.is_(None))
+    archives = await db.execute(query.order_by(PrintArchive.created_at.desc()))
+    return next((a for a in archives.scalars() if _archive_name_forms(a) & reported), None)
+
+
+async def running_print_archive(db, printer_id: int, state: PrinterState, *, printing_only: bool, by_name: bool):
+    """The archive of the print running on ``printer_id``, or None (#3327).
+
+    By the print's subtask_id first, which the firmware mints per print; with
+    ``printing_only`` only an archive that is still printing (a leftover row of
+    a completion never seen must not stand in for another job). A print
+    started outside the cloud (Bambu Studio over LAN, Handy, the printer's
+    screen) reports subtask_id "0", which links it to nothing: the archive
+    Bambuddy bound to the running print comes next, the one completion charges
+    and photographs. A restart forgets that binding and rebinds only Bambuddy's
+    own prints, so with ``by_name`` a "0" print last falls back to its printing
+    archive under the name the printer reports. That can be an earlier run of
+    the same name whose completion was missed while Bambuddy was down: good
+    enough for a plate label or a cover, not for which objects skip-objects
+    offers, so those callers leave it off. None until the archive exists, and
+    never a deleted one.
+    """
+    from backend.app.models.archive import PrintArchive
+
+    subtask_id = str(getattr(state, "subtask_id", "") or "").strip()
+    if subtask_id not in ("", "0"):
+        query = select(PrintArchive).where(
+            PrintArchive.printer_id == printer_id,
+            PrintArchive.subtask_id == subtask_id,
+            PrintArchive.deleted_at.is_(None),
+        )
+        if printing_only:
+            query = query.where(PrintArchive.status == "printing")
+        archive = await db.scalar(query.order_by(PrintArchive.created_at.desc()).limit(1))
+        if archive is not None:
+            return archive
+
+    bound = next(
+        (_active_prints[key] for key in _build_status_print_keys(printer_id, state) if key in _active_prints),
+        None,
+    )
+    if bound is not None:
+        return await db.scalar(select(PrintArchive).where(PrintArchive.id == bound, PrintArchive.deleted_at.is_(None)))
+    if not by_name or subtask_id != "0":
+        return None
+    reported = _reported_print_names(printer_id, state)
+    if not reported:
+        return None
+    return await _printing_archive_without_job_id(db, printer_id, reported, dispatched_only=False, live_only=True)
+
+
 def _bind_active_print(
     printer_id: int,
     archive_id: int,
@@ -948,23 +1024,10 @@ async def _is_bambuddy_authorized_print(printer_id: int, state: PrinterState, db
         # Bambuddy's printing archives on this printer under the name the
         # printer reports, compared as the printer echoes names (spaces become
         # underscores). Nothing reported to compare yet: wait.
-        reported = {_normalise_subtask_name(name) for _pid, name in possible_keys} - {""}
+        reported = _reported_print_names(printer_id, state)
         if not reported:
             return None
-        dispatched = await db.execute(
-            select(PrintArchive)
-            .where(
-                PrintArchive.printer_id == printer_id,
-                PrintArchive.status == "printing",
-                or_(PrintArchive.billing_run_id.is_not(None), PrintArchive.created_by_id.is_not(None)),
-                # An archive with a real id of its own is a print that reported
-                # one: a "0" print is not it, even under the same name (one whose
-                # completion was missed would otherwise cover it).
-                or_(PrintArchive.subtask_id.is_(None), PrintArchive.subtask_id.in_(("", "0"))),
-            )
-            .order_by(PrintArchive.created_at.desc())
-        )
-        archive = next((a for a in dispatched.scalars() if _archive_name_forms(a) & reported), None)
+        archive = await _printing_archive_without_job_id(db, printer_id, reported, dispatched_only=True)
     else:
         result = await db.execute(
             select(PrintArchive)
@@ -3759,33 +3822,20 @@ async def _restore_printable_objects(printer_id: int, state, db, logger) -> None
     object count, and the one endpoint that can rebuild the list is reachable
     only from the modal that button opens.
 
-    Anchored on ``subtask_id``, which the firmware mints per print, so a
-    leftover ``status="printing"`` row from a completion we never saw cannot
-    hand this print someone else's objects. Without one, nothing is loaded
-    rather than guessed — the reload path on ``GET /print/objects`` covers that
-    case on demand.
+    The archive is the running print's as ``running_print_archive`` finds it:
+    by subtask_id, or for a print that reports none by the archive bound to it
+    (#3327), never by name: a leftover ``status="printing"`` row from a
+    completion we never saw must not hand this print someone else's objects.
+    After a restart that binding exists only for Bambuddy's own prints, so a
+    print that reports no subtask_id loads nothing rather than a guess; the
+    reload path on ``GET /print/objects`` covers that case on demand.
     """
     client = printer_manager.get_client(printer_id)
     if client is None or client.state.printable_objects:
         return
 
-    subtask_id = str(getattr(state, "subtask_id", "") or "").strip()
-    if subtask_id in ("", "0"):
-        return
-
-    from backend.app.models.archive import PrintArchive
-
-    archive = await db.scalar(
-        select(PrintArchive)
-        .where(
-            PrintArchive.printer_id == printer_id,
-            PrintArchive.status == "printing",
-            PrintArchive.subtask_id == subtask_id,
-        )
-        .order_by(PrintArchive.created_at.desc())
-        .limit(1)
-    )
-    if archive is not None:
+    archive = await running_print_archive(db, printer_id, state, printing_only=True, by_name=False)
+    if archive is not None and archive.file_path:
         _load_objects_from_archive(archive, printer_id, logger)
 
 
@@ -4472,6 +4522,9 @@ async def _dispatched_by_bambuddy(db, printer_id: int, filename: str | None, sub
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     logger = logging.getLogger(__name__)
+    # The print's start, before the 3MF download that the archive waits for
+    # (#3327): a minute of it would otherwise be missing from the print time.
+    detected_at = datetime.now(timezone.utc)
 
     logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
 
@@ -5884,7 +5937,7 @@ async def on_print_start(printer_id: int, data: dict):
                     print_name=print_name,
                     print_time_seconds=fallback_print_time,
                     status="printing",
-                    started_at=datetime.now(timezone.utc),
+                    started_at=detected_at,
                     subtask_id=subtask_id,
                     confirm_requested=await _ask_outcome_for_external_print(db, printer_id, subtask_name),
                     filament_type=mqtt_filament_meta.get("filament_type"),
@@ -6030,6 +6083,7 @@ async def on_print_start(printer_id: int, data: dict):
                         **({"_3mf_from_archive": reused_3mf_from} if reused_3mf_from is not None else {}),
                     },
                     subtask_id=subtask_id,
+                    started_at=detected_at,
                     # The reused copy's temp name carries the printer id; a
                     # download's is the file's own name, as before.
                     original_filename=downloaded_filename if reused_3mf_from is not None else None,

+ 4 - 1
backend/app/services/archive.py

@@ -1297,6 +1297,7 @@ class ArchiveService:
         slicer_ams_mapping: list[int] | None = None,
         slicer_ams_mapping_printer_id: int | None = None,
         update_archive_id: int | None = None,
+        started_at: datetime | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -1345,6 +1346,8 @@ class ArchiveService:
                 already established from MQTT (``started_at``, ``subtask_id``,
                 ``created_by_id``, ``project_id``) are left alone; the 3MF has nothing
                 better to say about them.
+            started_at: When the print started, for a "printing" archive whose 3MF
+                was downloaded after the start (#3327). Defaults to now.
         """
         # Verify printer exists if specified
         if printer_id is not None:
@@ -1468,7 +1471,7 @@ class ArchiveService:
 
         # Determine status and timestamps
         status = print_data.get("status", "completed") if print_data else "archived"
-        started_at = datetime.now(timezone.utc) if status == "printing" else None
+        started_at = (started_at or datetime.now(timezone.utc)) if status == "printing" else None
         completed_at = datetime.now(timezone.utc) if status in ("completed", "failed", "archived") else None
 
         cost = await estimate_filament_cost(self.db, metadata.get("filament_used_grams"), metadata.get("filament_type"))

+ 363 - 0
backend/tests/integration/test_current_archive_lan_print_3327.py

@@ -0,0 +1,363 @@
+"""A print started outside the cloud is linked to its archive (#3327).
+
+Bambu Studio over LAN, Handy and the printer's screen report subtask_id "0",
+which Bambuddy stores as no id at all, so status found the archive of such a
+print by subtask_id never: current_archive_id stayed null for the whole print,
+and the printer card could not name the plate of a multi-plate file. Status now
+falls back to the archive Bambuddy bound to the running print, and after a
+restart, which forgets that binding, to its printing archive by name. The
+cover reads the running print's 3MF from that archive too instead of the
+printer. Skip-objects does only through the binding: by name it could offer an
+earlier run's objects to skip.
+"""
+
+import logging
+import zipfile
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+from backend.app.main import _active_prints, _restore_printable_objects
+from backend.app.services.bambu_mqtt import PrinterState
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+NAME = "Benchy plates"
+
+
+@pytest.fixture(autouse=True)
+def _bindings():
+    _active_prints.clear()
+    yield
+    _active_prints.clear()
+
+
+def _state(run_state="RUNNING", subtask_id="0"):
+    state = PrinterState()
+    state.connected = True
+    state.state = run_state
+    state.subtask_id = subtask_id
+    state.subtask_name = NAME
+    state.gcode_file = "/data/Metadata/plate_5.gcode"
+    return state
+
+
+async def _statuses(async_client, states):
+    with patch("backend.app.api.routes.printers.printer_manager") as manager:
+        manager.get_status = MagicMock(side_effect=lambda pid: states.get(pid))
+        manager.is_awaiting_plate_clear = MagicMock(return_value=False)
+        singles = {}
+        for pid in states:
+            response = await async_client.get(f"/api/v1/printers/{pid}/status")
+            assert response.status_code == 200
+            singles[pid] = response.json()["current_archive_id"]
+        bulk = await async_client.get("/api/v1/bulk/printer-statuses", params={"ids": ",".join(map(str, states))})
+        assert bulk.status_code == 200
+        assert {row["id"]: row["current_archive_id"] for row in bulk.json()} == singles
+    return singles
+
+
+async def test_a_lan_print_is_linked_to_its_archive(async_client, printer_factory, archive_factory):
+    printer = await printer_factory(model="H2C")
+    archive = await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
+    _active_prints[(printer.id, f"{NAME}.3mf")] = archive.id
+
+    linked = await _statuses(async_client, {printer.id: _state()})
+
+    assert linked[printer.id] == archive.id
+
+
+async def test_also_while_paused(async_client, printer_factory, archive_factory):
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, status="printing", subtask_id=None)
+    _active_prints[(printer.id, NAME)] = archive.id
+
+    linked = await _statuses(async_client, {printer.id: _state("PAUSE")})
+
+    assert linked[printer.id] == archive.id
+
+
+async def test_not_before_its_archive_exists(async_client, printer_factory, archive_factory):
+    """Nor to an archive of an earlier print that also had no id."""
+    printer = await printer_factory()
+    await archive_factory(printer.id, status="printing", subtask_id=None)
+    await archive_factory(printer.id, status="printing", subtask_id="0")
+
+    linked = await _statuses(async_client, {printer.id: _state()})
+
+    assert linked[printer.id] is None
+
+
+async def test_not_to_another_printers_print(async_client, printer_factory, archive_factory):
+    printer, other = await printer_factory(), await printer_factory()
+    archive = await archive_factory(other.id, status="printing", subtask_id=None)
+    _active_prints[(other.id, NAME)] = archive.id
+
+    linked = await _statuses(async_client, {printer.id: _state()})
+
+    assert linked[printer.id] is None
+
+
+async def test_not_once_the_print_is_over(async_client, printer_factory, archive_factory):
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, status="printing", subtask_id=None)
+    _active_prints[(printer.id, NAME)] = archive.id
+
+    linked = await _statuses(async_client, {printer.id: _state("FINISH")})
+
+    assert linked[printer.id] is None
+
+
+async def test_a_cloud_print_is_still_linked_by_its_id(async_client, printer_factory, archive_factory):
+    printer = await printer_factory()
+    by_id = await archive_factory(printer.id, status="printing", subtask_id="524288")
+    bound = await archive_factory(printer.id, status="printing", subtask_id=None)
+    _active_prints[(printer.id, NAME)] = bound.id
+
+    linked = await _statuses(async_client, {printer.id: _state(subtask_id="524288")})
+
+    assert linked[printer.id] == by_id.id
+
+
+async def test_a_cloud_print_without_an_archive_by_its_id_uses_the_binding(
+    async_client, printer_factory, archive_factory
+):
+    printer = await printer_factory()
+    bound = await archive_factory(printer.id, status="printing", subtask_id=None)
+    _active_prints[(printer.id, NAME)] = bound.id
+
+    linked = await _statuses(async_client, {printer.id: _state(subtask_id="524288")})
+
+    assert linked[printer.id] == bound.id
+
+
+class TestAfterARestart:
+    """The binding is gone and only Bambuddy's own prints are bound again."""
+
+    async def test_a_lan_print_is_found_by_its_name(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory(model="H2C")
+        await archive_factory(printer.id, status="printing", subtask_id=None, filename="Older.3mf", print_name="Older")
+        archive = await archive_factory(
+            printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf", print_name=NAME
+        )
+
+        linked = await _statuses(async_client, {printer.id: _state()})
+
+        assert linked[printer.id] == archive.id
+
+    async def test_as_the_printer_echoes_the_name(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
+        state = _state()
+        state.subtask_name = NAME.replace(" ", "_")
+
+        linked = await _statuses(async_client, {printer.id: state})
+
+        assert linked[printer.id] == archive.id
+
+    async def test_not_a_finished_run_of_the_same_file(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        await archive_factory(printer.id, status="completed", subtask_id=None, filename=f"{NAME}.3mf")
+
+        linked = await _statuses(async_client, {printer.id: _state()})
+
+        assert linked[printer.id] is None
+
+    async def test_not_a_print_that_had_an_id_of_its_own(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        await archive_factory(printer.id, status="printing", subtask_id="524288", filename=f"{NAME}.3mf")
+
+        linked = await _statuses(async_client, {printer.id: _state()})
+
+        assert linked[printer.id] is None
+
+    async def test_not_for_a_cloud_print(self, async_client, printer_factory, archive_factory):
+        """Its own id finds its archive; a name is no better than that."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
+
+        linked = await _statuses(async_client, {printer.id: _state(subtask_id="524288")})
+
+        assert linked[printer.id] is None
+
+    async def test_not_a_deleted_archive(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf", deleted_at=datetime.now()
+        )
+
+        linked = await _statuses(async_client, {printer.id: _state()})
+
+        assert linked[printer.id] is None
+
+
+class TestADeletedArchive:
+    """Deleting the archive of a running print leaves its binding behind."""
+
+    async def test_is_not_named_through_the_binding(self, async_client, printer_factory, archive_factory, db_session):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
+        _active_prints[(printer.id, NAME)] = archive.id
+        await db_session.delete(archive)
+        await db_session.commit()
+
+        linked = await _statuses(async_client, {printer.id: _state()})
+
+        assert linked[printer.id] is None
+
+    async def test_nor_when_moved_to_the_bin(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="printing", subtask_id=None, deleted_at=datetime.now())
+        _active_prints[(printer.id, NAME)] = archive.id
+
+        linked = await _statuses(async_client, {printer.id: _state()})
+
+        assert linked[printer.id] is None
+
+    async def test_nor_by_a_cloud_prints_id(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        await archive_factory(printer.id, status="printing", subtask_id="524288", deleted_at=datetime.now())
+
+        linked = await _statuses(async_client, {printer.id: _state(subtask_id="524288")})
+
+        assert linked[printer.id] is None
+
+
+def _job_3mf(tmp_path):
+    """A real zip without a cover, so the cover route answers 404 from it."""
+    path = tmp_path / "job.3mf"
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", "<config/>")
+    return str(path.relative_to(tmp_path))
+
+
+class TestTheRunningPrintsFileOnDisk:
+    """What else finds the running print's archive finds a "0" print's too."""
+
+    async def test_the_cover_comes_from_the_archive(self, async_client, printer_factory, archive_factory, tmp_path):
+        printer = await printer_factory(model="H2C")
+        await archive_factory(
+            printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf", file_path=_job_3mf(tmp_path)
+        )
+        state = _state()
+        state.sdcard = True
+        ftp = AsyncMock(return_value=None)
+
+        with (
+            patch.object(app_settings, "base_dir", tmp_path),
+            patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
+            patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
+            patch("backend.app.api.routes.printers.download_file_try_paths_async", new=ftp),
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
+
+        assert response.status_code == 404, response.text
+        ftp.assert_not_awaited()
+
+    async def test_the_skip_objects_reload_reads_the_archive(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory(model="H2C")
+        archive = await archive_factory(printer.id, status="printing", subtask_id=None)
+        _active_prints[(printer.id, NAME)] = archive.id
+        client = MagicMock(state=_state())
+        ftp = AsyncMock(return_value=None)
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client),
+            patch(
+                "backend.app.services.archive.extract_printable_objects_from_archive",
+                return_value=({101: {"name": "left"}}, None),
+            ) as from_disk,
+            patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp),
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+        assert response.status_code == 200, response.text
+        assert response.json()["total"] == 1
+        from_disk.assert_called_once()
+        ftp.assert_not_awaited()
+
+    async def test_the_object_list_comes_from_the_bound_archive(self, printer_factory, archive_factory, db_session):
+        """A reconnect without a restart: the binding is still there."""
+        printer = await printer_factory(model="H2C")
+        archive = await archive_factory(printer.id, status="printing", subtask_id=None)
+        _active_prints[(printer.id, NAME)] = archive.id
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._load_objects_from_archive") as load,
+        ):
+            pm.get_client.return_value = MagicMock(state=MagicMock(printable_objects={}))
+            await _restore_printable_objects(printer.id, _state(), db_session, logging.getLogger(__name__))
+
+        load.assert_called_once()
+        assert load.call_args.args[0].id == archive.id
+
+    async def test_but_never_by_name_after_a_restart(self, printer_factory, archive_factory, db_session):
+        """An earlier run of the same name, left printing by a completion
+        missed while Bambuddy was down, cannot be told from this one."""
+        printer = await printer_factory(model="H2C")
+        await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._load_objects_from_archive") as load,
+        ):
+            pm.get_client.return_value = MagicMock(state=MagicMock(printable_objects={}))
+            await _restore_printable_objects(printer.id, _state(), db_session, logging.getLogger(__name__))
+
+        load.assert_not_called()
+
+    async def test_nor_does_the_reload(self, async_client, printer_factory, archive_factory):
+        """It asks the printer for the file it is running instead."""
+        printer = await printer_factory(model="H2C")
+        await archive_factory(printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf")
+        client = MagicMock(state=_state())
+        ftp = AsyncMock(return_value=None)
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client),
+            patch(
+                "backend.app.services.archive.extract_printable_objects_from_archive",
+                return_value=({101: {"name": "left"}}, None),
+            ) as from_disk,
+            patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp),
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
+
+        assert response.status_code == 200, response.text
+        from_disk.assert_not_called()
+        ftp.assert_awaited()
+
+    async def test_not_from_an_archive_without_its_3mf(self, printer_factory, archive_factory, db_session):
+        printer = await printer_factory(model="H2C")
+        archive = await archive_factory(
+            printer.id, status="printing", subtask_id=None, filename=f"{NAME}.3mf", file_path=""
+        )
+        _active_prints[(printer.id, NAME)] = archive.id
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._load_objects_from_archive") as load,
+        ):
+            pm.get_client.return_value = MagicMock(state=MagicMock(printable_objects={}))
+            await _restore_printable_objects(printer.id, _state(), db_session, logging.getLogger(__name__))
+
+        load.assert_not_called()
+
+    async def test_nor_from_a_finished_archive_with_the_same_job_id(self, printer_factory, archive_factory, db_session):
+        """A leftover row of a completion never seen must not lend its objects."""
+        printer = await printer_factory(model="H2C")
+        await archive_factory(printer.id, status="completed", subtask_id="524288", filename=f"{NAME}.3mf")
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._load_objects_from_archive") as load,
+        ):
+            pm.get_client.return_value = MagicMock(state=MagicMock(printable_objects={}))
+            await _restore_printable_objects(
+                printer.id, _state(subtask_id="524288"), db_session, logging.getLogger(__name__)
+            )
+
+        load.assert_not_called()

+ 2 - 2
backend/tests/unit/test_cover_coalescing_2572.py

@@ -46,7 +46,7 @@ def _clear_cover_state():
 @pytest.mark.asyncio
 async def test_concurrent_cover_requests_download_once():
     printer = SimpleNamespace(id=1, ip_address="127.0.0.1", access_code="x", model="X1C", name="P")
-    state = SimpleNamespace(subtask_name="job", state="RUNNING")
+    state = SimpleNamespace(subtask_name="job", state="RUNNING", subtask_id="", gcode_file="", current_print="")
 
     produce_calls = {"n": 0}
 
@@ -74,7 +74,7 @@ async def test_concurrent_cover_requests_download_once():
 async def test_second_request_serves_from_positive_cache():
     """A follower arriving after the leader filled the cache serves it directly."""
     printer = SimpleNamespace(id=1, ip_address="127.0.0.1", access_code="x", model="X1C", name="P")
-    state = SimpleNamespace(subtask_name="job", state="RUNNING")
+    state = SimpleNamespace(subtask_name="job", state="RUNNING", subtask_id="", gcode_file="", current_print="")
 
     produce_calls = {"n": 0}
 

+ 87 - 0
backend/tests/unit/test_print_start_time_3327.py

@@ -0,0 +1,87 @@
+"""An archive's started_at is when the print started, not when it was written (#3327).
+
+The archive waits for the 3MF, which the printer serves slowly while it starts:
+the reporter's H2C archived its print a minute after the start. Completion takes
+the print time from started_at, so that minute was missing from it. The fallback
+archive, written only after every download attempt failed, waited longer still.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+
+from backend.app.services.archive import ArchiveService
+from backend.tests.unit.test_archive_print_plate import _three_mf, dirs, printer_id  # noqa: F401
+from backend.tests.unit.test_print_start_no_second_download_3308 import (  # noqa: F401
+    FILE,
+    _isolated,
+    _listed,
+    _print_start,
+    _served_on_second_try,
+    _times_out_at_root,
+)
+
+
+def _timed(transfer):
+    """*transfer*, noting when the first download attempt began."""
+    began: list[datetime] = []
+
+    async def timed(remote_path, local_path):
+        began.append(datetime.now(timezone.utc))
+        return await transfer(remote_path, local_path)
+
+    return timed, began
+
+
+class TestOnPrintStart:
+    @pytest.mark.asyncio
+    async def test_the_archive_starts_when_the_print_was_seen(self):
+        before = datetime.now(timezone.utc)
+        transfer, began = _timed(_served_on_second_try(f"/cache/{FILE}"))
+
+        run = await _print_start(transfer=transfer, listing=_listed(FILE))
+
+        started_at = run.service.archive_print.await_args.kwargs["started_at"]
+        assert before <= started_at <= began[0]
+
+    @pytest.mark.asyncio
+    async def test_so_does_the_fallback_archive(self):
+        before = datetime.now(timezone.utc)
+        transfer, began = _timed(_times_out_at_root)
+
+        run = await _print_start(transfer=transfer, listing=_listed(FILE))
+
+        assert before <= run.fallback.started_at <= began[0]
+
+
+class TestArchivePrint:
+    @pytest.mark.asyncio
+    async def test_a_given_start_is_kept(self, db_session, dirs, printer_id):  # noqa: F811
+        start = datetime(2026, 10, 15, 2, 48, 54, tzinfo=timezone.utc)
+
+        archive = await ArchiveService(db_session).archive_print(
+            printer_id, _three_mf(dirs / "p.3mf", [1]), print_data={"status": "printing"}, started_at=start
+        )
+
+        assert archive.started_at.replace(tzinfo=timezone.utc) == start
+
+    @pytest.mark.asyncio
+    async def test_without_one_it_is_now(self, db_session, dirs, printer_id):  # noqa: F811
+        before = datetime.now(timezone.utc)
+
+        archive = await ArchiveService(db_session).archive_print(
+            printer_id, _three_mf(dirs / "p.3mf", [1]), print_data={"status": "printing"}
+        )
+
+        assert archive.started_at.replace(tzinfo=timezone.utc) >= before.replace(microsecond=0)
+
+    @pytest.mark.asyncio
+    async def test_a_finished_archive_has_none(self, db_session, dirs, printer_id):  # noqa: F811
+        archive = await ArchiveService(db_session).archive_print(
+            printer_id,
+            _three_mf(dirs / "p.3mf", [1]),
+            print_data={"status": "completed"},
+            started_at=datetime(2026, 10, 15, tzinfo=timezone.utc),
+        )
+
+        assert archive.started_at is None

+ 1 - 1
backend/tests/unit/test_skip_objects_survive_restart.py

@@ -75,7 +75,7 @@ class TestRestartRecovery:
         the case on demand instead."""
         from backend.app.main import _restore_printable_objects
 
-        state = MagicMock(subtask_id=subtask_id)
+        state = MagicMock(subtask_id=subtask_id, subtask_name="job", gcode_file="", current_print="")
         db = _db(MagicMock())
 
         with (