Bläddra i källkod

Restore the skip-objects list after a restart mid-print

The object list lives in PrinterState and is filled by the print-start path,
which bambu_mqtt suppresses on the first RUNNING push after startup so a
running print is not archived twice (#1304). Everything else that moment
restores came back - the archive into _active_prints, the filament
attribution session, the timelapse baseline - and the object list did not.
So the card saw zero objects and greyed out its Skip button for the rest of
the print. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a
restart at 09:17, Skip dead for the remaining hour.

Nothing could bring it back either. GET /print/objects rebuilds the list
whenever it is empty, but its only caller is the modal that the greyed-out
button opens.

on_print_running_observed now reloads the objects from the archive of the
print that is still running, anchored on subtask_id - the firmware mints one
per print, so a leftover status="printing" row from a completion that was
never seen cannot lend its objects to another job. Without an id nothing is
loaded rather than guessed; the endpoint's own reload covers that on demand.

That endpoint now reads the archived 3MF from disk before it asks the
printer. The archive of a running print normally holds the very file the
printer is executing, so the fan-out was fetching back 15 MB Bambuddy
already had, over the printer's single FTP socket, while it was printing -
and on a printer that kept the file on internal storage it cannot succeed at
all. FTP stays as the fallback. skipped_objects is left alone: a reload is
not a new print, and what the user has already skipped only lives there.

The plate image had the same fault one layer down. Opening the modal asks
for the cover, the top view and the object-ID mask, and the in-memory 3MF
cache those share dies with the process - so after a restart all three went
back to the printer at once: three fan-outs, thirteen seconds, and a 0-byte
read from socket contention, which is the storm #972 was about. The cover
flow takes the running print's archived file too, resolved in the caller's
short-lived session and passed in so _produce_cover_image still does no DB
work, and marked as a shared file so the cleanup cannot delete the archive.

Finally the card: a running print always has at least one object, so a count
of zero means "not loaded", not "nothing to skip". Exactly one object is the
real nothing-to-skip case and still disables the button.

---

Stop a test's printer client leaking into the next test

POST /api/v1/printers really connects, so a test that creates a printer
through the API leaves a live client in the printer_manager singleton. The
singleton outlives the per-test in-memory database, so the next test on that
xdist worker - whose own first printer is handed the same primary key - reads
that leftover client as its own live status.

test_scheduled_drying_routes was the visible victim: an "online" printer with
no firmware version fails the drying preflight, so scheduling came back 400
instead of 200. It only bites when --dist load happens to put victim and
leaker on one worker, which is why it passes on its own and flakes under -n.

Registrations made during a test are now undone after it, ids the test did not
add are left alone, and disconnect_printer is what also drops the model and
printer-info caches and stops the paho thread the leaked client was keeping
alive against an unreachable address for the rest of the run.
maziggy 2 veckor sedan
förälder
incheckning
cfecfa360e

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **Skip Objects went dead for the rest of a print if Bambuddy restarted while it was running** — The object list lives in memory and is filled by the print-start path, which is deliberately suppressed on the first status push after a restart so the print is not archived twice. Everything else that moment restores — the archive, filament attribution, the timelapse baseline — came back; the object list did not, so the printer card saw zero objects and greyed out its Skip button. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a restart at 09:17, and no way to skip anything for the remaining hour of the print. Nothing could recover it either, because the one endpoint that can rebuild the list is only reachable from the modal that the greyed-out button opens. The list is now restored on the way back up, from the archive of the print that is still running and matched on the job id the printer mints per print, so a stale archive cannot lend its objects to someone else's job. Two things behind it changed as well: rebuilding now reads the archived 3MF on disk before asking the printer for a file Bambuddy already has — that request was a full transfer off a machine mid-print, 15 MB in this case, and it cannot succeed at all on a printer that kept the file on internal storage — and the card now treats zero objects as "not loaded yet" rather than "nothing to skip", since a running print always has at least one. A single-object print still greys the button out, which is the case that rule was written for. The plate image in the modal came from the same place and had the same problem: the cover, the top view and the object-ID mask all re-fetched the 3MF from the printer after a restart, three fan-outs at once for one modal, so the picture arrived seconds after the list. They now read the running print's archived file too. Wiki updated. Covered by backend and frontend tests.
 - **A print archived without its 3MF can be given its filament weight by hand (#1820, reported by @ojimpo)** — When the sliced file stays somewhere Bambuddy cannot read, the archive is created from the printer's report alone and carries no weight, so the print is missing from every filament total and there was no way to put it right afterwards: Rescan reads the figure out of the 3MF, and that archive has no file to read. The reporter's H2S print left 46 g of PLA on the spool with nothing recording it, and he corrected Spoolman by hand. **Edit Archive** now has a **Filament used (g)** field. It is written to the print's most recent run as well as to the archive, because the Projects roll-up and the Prometheus counter sum the runs rather than the cards — correcting only the card would have fixed the display and left every aggregate reading the old figure. The value is bounded at 0 to 100 kg, it is sent only when you actually change it, so an ordinary save cannot round off a sliced figure, and emptying the field clears it. A run that measured its own weight through spool tracking keeps that measurement — the correction fills in a run that has none, or one that only ever inherited the archive's estimate, and never overwrites a real measurement with a typed one. Nothing is deducted from Spoolman or internal inventory either: those are charged from what was tracked at the time, and a print that recorded nothing has nothing to reverse. On an archive that does have its 3MF, Rescan still overwrites what you typed — there the file is the authority. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **The internal-storage probe now logs which directory served the file (#1820)** — When a printer says a print went to internal storage and Bambuddy finds it over FTPS anyway (#2856), the log said which file but not where it came from. On a printer that keeps uploads for weeks — the reporter's H2S has months of them in `/cache` — a reprint of a name that was re-sliced but never re-sent can match an older copy, and without the directory in the log that mismatch was invisible rather than merely rare. The download helper now reports the path that served the file instead of a bare success flag, and the hit line names it. Covered by backend tests.
 - **Card and row actions were unreachable on phones and tablets (#2865, reported by @aishlai)** — The three-dot menu on a project card carries Edit and Delete and appeared only on hover, so on a phone there was no way to rename or delete a project at all; the File Manager's folder actions went the same way, as did duplicating a preset, renaming or deleting a tag, removing a print photo and deleting a plate-reference image. This is worse than a missing hover event: Tailwind v4 compiles `hover:` and `group-hover:` inside `@media (hover: hover)`, so on a touch-only device the rule that reveals the control is not merely never triggered, it is never applied — the element stays invisible for good. The hiding half is now what depends on a hover-capable pointer, so a device that cannot hover simply shows the control, and a mouse behaves exactly as before. Four spots on the Archives cards and two on the File Manager's file cards already tried to handle this by viewport width, under 768 pixels, which meant a phone was fine and a tablet in landscape was not; they now use the same capability check and the width guess is gone. Keyboard users were affected too, in the other direction — the buttons were invisible but still focusable, so tabbing through a card stopped on something nobody could see; focus now reveals them. Wiki updated. Covered by frontend tests.

+ 0 - 0
assert


+ 105 - 2
backend/app/api/routes/printers.py

@@ -2,6 +2,7 @@ import asyncio
 import logging
 import re
 import zipfile
+from pathlib import Path
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import Response
@@ -1083,6 +1084,41 @@ def clear_cover_cache(printer_id: int) -> None:
     _cover_404_cache.pop(printer_id, 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.
+
+    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
+
+    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 None or not archive.file_path:
+        return None
+
+    path = settings.base_dir / archive.file_path
+    return path if path.is_file() and str(path).endswith(".3mf") else None
+
+
 @router.get("/{printer_id}/cover")
 async def get_printer_cover(
     printer_id: int,
@@ -1175,7 +1211,16 @@ async def get_printer_cover(
     fut: asyncio.Future = asyncio.get_event_loop().create_future()
     _cover_inflight[inflight_key] = fut
     try:
-        image_data = await _produce_cover_image(printer, printer_id, subtask_name, view, view_key, plate_num, cache_key)
+        image_data = await _produce_cover_image(
+            printer,
+            printer_id,
+            subtask_name,
+            view,
+            view_key,
+            plate_num,
+            cache_key,
+            archive_path=await _running_print_archive_file(printer_id, state),
+        )
         return Response(content=image_data, media_type="image/png")
     finally:
         if not fut.done():
@@ -1191,6 +1236,7 @@ async def _produce_cover_image(
     view_key: str,
     plate_num: int | None,
     cache_key: tuple[str, str],
+    archive_path: Path | None = None,
 ) -> bytes:
     """Download the active-print 3MF and extract its cover thumbnail (#2572).
 
@@ -1198,7 +1244,9 @@ async def _produce_cover_image(
     can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
     on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
     failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
-    work — the caller already released the pooled connection before this runs.
+    work — the caller already released the pooled connection before this runs,
+    which is also why ``archive_path`` arrives resolved rather than looked up
+    here.
     """
     # Build possible 3MF filenames from subtask_name
     # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
@@ -1260,6 +1308,20 @@ async def _produce_cover_image(
             using_cached = True
             break
 
+    if not downloaded:
+        # Same idea, one step further back: that in-memory cache dies with the
+        # process, but the archive of the print that is still running holds the
+        # very 3MF on disk. Without this, reopening a card or the skip-objects
+        # plate after a restart pulls the whole file back off a printer that is
+        # mid-print — measured at three concurrent fan-outs, thirteen seconds
+        # and a 0-byte read on the maintainer's H2C, which is exactly the
+        # single-socket contention #972 was about.
+        if archive_path is not None:
+            logger.info("Cover using the running print's archived 3MF at %s (no FTP)", archive_path)
+            temp_path = archive_path
+            downloaded = True
+            using_cached = True
+
     if not downloaded:
         # The cover lives inside the 3MF, so it is only reachable if the 3MF is.
         # When the printer kept the print on internal storage there is nothing
@@ -3663,6 +3725,47 @@ async def get_printable_objects(
 
     # Reload objects from 3MF if requested or no objects loaded
     if reload or not client.state.printable_objects:
+        # The archive of a running print normally holds the very file the
+        # printer is executing, so ask the disk before asking the printer:
+        # the fan-out below pulls the whole 3MF over FTPS from a machine that
+        # is mid-print — 15 MB on the print this was written for — and on a
+        # printer that kept the file on internal storage it cannot succeed at
+        # 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.services.archive import extract_printable_objects_from_archive
+
+        subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip()
+        if subtask_id not in ("", "0"):
+            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),
+                )
+                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.
+    if not client.state.printable_objects:
         subtask_name = client.state.subtask_name
         if subtask_name:
             from backend.app.services.archive import extract_printable_objects_from_3mf

+ 59 - 19
backend/app/main.py

@@ -2860,34 +2860,73 @@ async def _dispatch_user_print_email(
 def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
     """Extract printable objects from an archive's 3MF file and store in printer state."""
     try:
-        from backend.app.services.archive import extract_printable_objects_from_3mf
+        from backend.app.services.archive import extract_printable_objects_from_archive
 
         client = printer_manager.get_client(printer_id)
         if not client:
             return
 
-        file_path = app_settings.base_dir / archive.file_path
-        if file_path.is_file() and str(file_path).endswith(".3mf"):
-            with open(file_path, "rb") as f:
-                threemf_data = f.read()
-            # Extract with positions for UI overlay, scoped to the plate that
-            # is printing — resolve_plate_id is the same resolver /cover uses,
-            # so the object list can't disagree with the thumbnail it is drawn
-            # over (#2522).
-            printable_objects, bbox_all = extract_printable_objects_from_3mf(
-                threemf_data,
-                plate_number=resolve_plate_id(client.state),
-                include_positions=True,
-            )
-            if printable_objects:
-                client.state.printable_objects = printable_objects
-                client.state.printable_objects_bbox_all = bbox_all
-                client.state.skipped_objects = []
-                logger.info("Loaded %s printable objects for printer %s", len(printable_objects), printer_id)
+        # Extract with positions for UI overlay, scoped to the plate that
+        # is printing — resolve_plate_id is the same resolver /cover uses,
+        # so the object list can't disagree with the thumbnail it is drawn
+        # over (#2522).
+        printable_objects, bbox_all = extract_printable_objects_from_archive(
+            app_settings.base_dir / archive.file_path,
+            plate_number=resolve_plate_id(client.state),
+        )
+        if printable_objects:
+            client.state.printable_objects = printable_objects
+            client.state.printable_objects_bbox_all = bbox_all
+            client.state.skipped_objects = []
+            logger.info("Loaded %s printable objects for printer %s", len(printable_objects), printer_id)
     except Exception as e:
         logger.debug("Failed to extract printable objects from archive: %s", e)
 
 
+async def _restore_printable_objects(printer_id: int, state, db, logger) -> None:
+    """Put the skip-objects list back after a restart mid-print.
+
+    ``PrinterState.printable_objects`` is in-memory only, and the only thing
+    that fills it is ``_load_objects_from_archive`` on the print-start paths —
+    which the #1304 guard suppresses on the first RUNNING push after startup.
+    Everything else this hook restores (the archive, the usage-tracking session,
+    the timelapse baseline) was already handled; the object list was not, so a
+    restart mid-print took skip-objects away for the rest of that print.
+
+    Nothing recovered it either: the printer card gates its Skip button on the
+    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.
+    """
+    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:
+        _load_objects_from_archive(archive, printer_id, logger)
+
+
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     logger = logging.getLogger(__name__)
@@ -4798,6 +4837,7 @@ async def on_print_running_observed(printer_id: int, data: dict):
                 logger.info("[RESTART] Restored active Bambuddy print for printer %s", printer_id)
 
             await _restore_usage_tracking_session(printer_id, state, db, logger)
+            await _restore_printable_objects(printer_id, state, db, logger)
 
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         printer = result.scalar_one_or_none()

+ 20 - 0
backend/app/services/archive.py

@@ -603,6 +603,26 @@ class ThreeMFParser:
                 break
 
 
+def extract_printable_objects_from_archive(
+    file_path: Path, plate_number: int | None = None
+) -> tuple[dict[int, dict], list | None]:
+    """Objects and plate bbox for an archived print, read off local disk.
+
+    The archive of a running print usually holds the very 3MF the printer is
+    executing, so the object list can be rebuilt without asking the printer for
+    a file we already have -- 15 MB over FTPS from a machine that is mid-print,
+    in the case this was written for. Returns empty when the archive has
+    no readable 3MF, which is the caller's signal to fall back to the printer.
+    """
+    if not file_path.is_file() or not str(file_path).endswith(".3mf"):
+        return {}, None
+    try:
+        data = file_path.read_bytes()
+    except OSError:
+        return {}, None
+    return extract_printable_objects_from_3mf(data, plate_number=plate_number, include_positions=True)
+
+
 def extract_printable_objects_from_3mf(
     data: bytes, plate_number: int | None = None, include_positions: bool = False
 ) -> dict[int, str] | dict[int, dict] | tuple[dict[int, dict], list | None]:

+ 25 - 0
backend/tests/conftest.py

@@ -167,6 +167,31 @@ def reset_auth_enabled_cache():
     invalidate_auth_enabled_cache()
 
 
+@pytest.fixture(autouse=True)
+def disconnect_printers_registered_during_a_test():
+    """Hand the ``printer_manager`` singleton back the way the test found it.
+
+    ``POST /api/v1/printers`` really calls ``connect_printer``, so a test that
+    creates a printer through the API parks a live client in the singleton --
+    and the singleton outlives the per-test in-memory database. The next test
+    on the same xdist worker gets a fresh database whose first printer is handed
+    the same primary key, and reads that leftover client as its own live status.
+    ``test_scheduled_drying_routes`` saw exactly that: an "online" printer with
+    no firmware version, so scheduling a dry came back 400 instead of 200.
+
+    Only ids this test added are dropped, so a client registered by a wider
+    fixture stays registered. ``disconnect_printer`` is what clears the model
+    and printer-info caches too, and it stops the paho thread the leaked client
+    would otherwise keep retrying on for the rest of the run.
+    """
+    from backend.app.services.printer_manager import printer_manager
+
+    before = set(printer_manager._clients)
+    yield
+    for printer_id in set(printer_manager._clients) - before:
+        printer_manager.disconnect_printer(printer_id)
+
+
 @pytest.fixture(scope="session")
 def event_loop():
     """Create an instance of the default event loop for each test session."""

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

@@ -4494,3 +4494,186 @@ class TestClearPlateOnAPoweredDownPrinter:
         body = response.json()
         assert body["connected"] is False
         assert body["awaiting_plate_clear"] is True
+
+
+class TestPrintableObjectsReload:
+    """Rebuilding the object list must ask the disk before the printer.
+
+    The archive of a running print normally holds the very 3MF the printer is
+    executing. Pulling it back over FTPS costs a full transfer from a machine
+    that is mid-print — 15 MB on the print this was written for — and on a
+    printer that kept the file on internal storage it cannot succeed at all.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_archive_on_disk_is_used_before_any_ftp(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        printer = await printer_factory(name="H2C-1")
+        await archive_factory(printer.id, status="printing", subtask_id="330151809")
+
+        client = MagicMock()
+        client.state = MagicMock(
+            printable_objects={},
+            skipped_objects=[],
+            subtask_id="330151809",
+            subtask_name="HULA",
+            state="RUNNING",
+            gcode_file="/data/Metadata/plate_1.gcode",
+            printable_objects_bbox_all=None,
+        )
+        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"}, 102: {"name": "right"}}, 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"] == 2
+        from_disk.assert_called_once()
+        ftp.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_reload_that_the_disk_cannot_answer_still_asks_the_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """The fan-out is the fallback, not dead code: a print whose archive has
+        no 3MF (or none at all) still has the printer to ask."""
+        printer = await printer_factory(name="H2C-1")
+        await archive_factory(printer.id, status="printing", subtask_id="330151809")
+
+        client = MagicMock()
+        client.state = MagicMock(
+            printable_objects={},
+            skipped_objects=[],
+            subtask_id="330151809",
+            subtask_name="HULA",
+            state="RUNNING",
+            gcode_file="/data/Metadata/plate_1.gcode",
+            printable_objects_bbox_all=None,
+        )
+        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=({}, None),
+            ),
+            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
+        ftp.assert_awaited()
+
+
+class TestCoverUsesTheRunningPrintsArchive:
+    """The cover must not re-fetch a 3MF Bambuddy already has on disk.
+
+    The in-memory download cache dies with the process, so after a restart
+    mid-print every cover, top view and skip-objects plate mask went back to the
+    printer for the whole file — measured on the maintainer's H2C as three
+    concurrent fan-outs, thirteen seconds and a 0-byte read, on a machine that
+    was busy printing. The archive of the running print holds that exact file.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_archived_3mf_is_preferred_over_ftp(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session, tmp_path
+    ):
+        import zipfile
+
+        from backend.app.core.config import settings as app_settings
+
+        printer = await printer_factory(name="H2C-1")
+        # A 3MF that is a real zip but carries no cover, so the endpoint reaches
+        # its "no cover in this file" answer without any FTP work.
+        threemf = tmp_path / "job.3mf"
+        with zipfile.ZipFile(threemf, "w") as zf:
+            zf.writestr("Metadata/slice_info.config", "<config/>")
+        await archive_factory(
+            printer.id,
+            status="printing",
+            subtask_id="330151809",
+            file_path=str(threemf.relative_to(tmp_path)),
+        )
+
+        state = MagicMock(
+            subtask_name="job",
+            subtask_id="330151809",
+            state="RUNNING",
+            gcode_file="/data/Metadata/plate_1.gcode",
+            current_project_url="ftp://job.3mf",
+            sdcard=True,
+            sdcard_reported=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.resolve_plate_id", return_value=1),
+            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")
+
+        # No cover inside this 3MF, so a 404 — but reached from the file on disk.
+        assert response.status_code == 404, response.text
+        ftp.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_archived_file_is_never_deleted_by_the_cover_flow(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session, tmp_path
+    ):
+        """The cover flow unlinks the file it downloaded. Handed the archive's
+        own copy, that would delete the print's 3MF."""
+        import zipfile
+
+        from backend.app.core.config import settings as app_settings
+
+        printer = await printer_factory(name="H2C-1")
+        threemf = tmp_path / "job.3mf"
+        with zipfile.ZipFile(threemf, "w") as zf:
+            zf.writestr("Metadata/slice_info.config", "<config/>")
+        await archive_factory(
+            printer.id,
+            status="printing",
+            subtask_id="330151809",
+            file_path=str(threemf.relative_to(tmp_path)),
+        )
+
+        state = MagicMock(
+            subtask_name="job",
+            subtask_id="330151809",
+            state="RUNNING",
+            gcode_file="/data/Metadata/plate_1.gcode",
+            current_project_url="ftp://job.3mf",
+            sdcard=True,
+            sdcard_reported=True,
+        )
+
+        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.resolve_plate_id", return_value=1),
+            patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
+            patch(
+                "backend.app.api.routes.printers.download_file_try_paths_async",
+                new=AsyncMock(return_value=None),
+            ),
+        ):
+            await async_client.get(f"/api/v1/printers/{printer.id}/cover")
+
+        assert threemf.is_file(), "the cover flow deleted the running print's archived 3MF"

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

@@ -50,7 +50,9 @@ async def test_concurrent_cover_requests_download_once():
 
     produce_calls = {"n": 0}
 
-    async def slow_produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key):
+    async def slow_produce(
+        printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key, archive_path=None
+    ):
         produce_calls["n"] += 1
         await asyncio.sleep(0.1)  # hold leadership long enough for followers to attach
         printers_mod._cover_cache.setdefault(printer_id, {})[cache_key] = b"PNGDATA"
@@ -76,7 +78,7 @@ async def test_second_request_serves_from_positive_cache():
 
     produce_calls = {"n": 0}
 
-    async def produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key):
+    async def produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key, archive_path=None):
         produce_calls["n"] += 1
         printers_mod._cover_cache.setdefault(printer_id, {})[cache_key] = b"PNGDATA"
         return b"PNGDATA"

+ 166 - 0
backend/tests/unit/test_skip_objects_survive_restart.py

@@ -0,0 +1,166 @@
+"""The skip-objects list has to survive a restart mid-print.
+
+``PrinterState.printable_objects`` is in-memory and is filled by the print-start
+path, which the #1304 guard suppresses on the first RUNNING push after Bambuddy
+comes back up. So a restart during a print left the list empty for the rest of
+that print: the printer card gates its Skip button on the object count, and the
+one endpoint that can rebuild the list is reachable only from the modal that
+button opens. Measured on the maintainer's H2C — 8 objects loaded at 09:02,
+restart at 09:17, Skip dead for the remaining hour.
+
+The recovery hook now reloads the objects from the archive of the print that is
+still running, anchored on the subtask_id the firmware mints per print.
+"""
+
+import logging
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+def _client(objects=None):
+    client = MagicMock()
+    client.state = MagicMock(printable_objects=objects if objects is not None else {})
+    return client
+
+
+def _db(archive):
+    db = AsyncMock()
+    db.scalar = AsyncMock(return_value=archive)
+    return db
+
+
+class TestRestartRecovery:
+    @pytest.mark.asyncio
+    async def test_objects_are_reloaded_from_the_running_print_s_archive(self):
+        from backend.app.main import _restore_printable_objects
+
+        archive = MagicMock(id=360, file_path="archive/7/job/job.3mf")
+        state = MagicMock(subtask_id="330151809")
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._load_objects_from_archive") as load,
+        ):
+            pm.get_client.return_value = _client()
+            await _restore_printable_objects(7, state, _db(archive), logging.getLogger(__name__))
+
+        load.assert_called_once()
+        assert load.call_args.args[0] is archive
+        assert load.call_args.args[1] == 7
+
+    @pytest.mark.asyncio
+    async def test_a_list_that_is_already_loaded_is_left_alone(self):
+        """The hook also fires on a reconnect during a print Bambuddy saw start.
+        Reloading there would discard what the user has already skipped, which
+        only lives alongside the object list."""
+        from backend.app.main import _restore_printable_objects
+
+        state = MagicMock(subtask_id="330151809")
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._load_objects_from_archive") as load,
+        ):
+            pm.get_client.return_value = _client({1: "cube"})
+            await _restore_printable_objects(7, state, _db(MagicMock()), logging.getLogger(__name__))
+
+        load.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("subtask_id", ["", "0", None])
+    async def test_nothing_is_loaded_without_a_subtask_to_anchor_on(self, subtask_id):
+        """A leftover status="printing" row from a completion we never saw must
+        not hand this print someone else's objects. The endpoint's reload covers
+        the case on demand instead."""
+        from backend.app.main import _restore_printable_objects
+
+        state = MagicMock(subtask_id=subtask_id)
+        db = _db(MagicMock())
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._load_objects_from_archive") as load,
+        ):
+            pm.get_client.return_value = _client()
+            await _restore_printable_objects(7, state, db, logging.getLogger(__name__))
+
+        load.assert_not_called()
+        db.scalar.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_no_matching_archive_is_not_an_error(self):
+        from backend.app.main import _restore_printable_objects
+
+        state = MagicMock(subtask_id="330151809")
+
+        with (
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._load_objects_from_archive") as load,
+        ):
+            pm.get_client.return_value = _client()
+            await _restore_printable_objects(7, state, _db(None), logging.getLogger(__name__))
+
+        load.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_the_recovery_hook_calls_it(self):
+        """Wiring guard: the restore is only useful if on_print_running_observed
+        runs it alongside the archive and usage-tracking restores."""
+        import backend.app.main as main
+
+        state = MagicMock(subtask_id="330151809", subtask_name="job")
+        session = AsyncMock()
+        session.__aenter__ = AsyncMock(return_value=session)
+        session.__aexit__ = AsyncMock()
+        session.execute = AsyncMock(
+            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+        )
+
+        with (
+            patch("backend.app.main.async_session", return_value=session),
+            patch("backend.app.main.printer_manager") as pm,
+            patch("backend.app.main._is_bambuddy_authorized_print", new=AsyncMock(return_value=True)),
+            patch("backend.app.main._restore_usage_tracking_session", new=AsyncMock()),
+            patch("backend.app.main._restore_printable_objects", new=AsyncMock()) as restore,
+        ):
+            pm.get_status.return_value = state
+            await main.on_print_running_observed(7, {})
+
+        restore.assert_awaited_once()
+        assert restore.await_args.args[0] == 7
+
+
+class TestExtractFromArchiveFile:
+    def test_a_missing_file_is_empty_rather_than_an_error(self, tmp_path):
+        from backend.app.services.archive import extract_printable_objects_from_archive
+
+        assert extract_printable_objects_from_archive(tmp_path / "gone.3mf") == ({}, None)
+
+    def test_a_fallback_archive_with_no_3mf_is_empty(self, tmp_path):
+        """An archive created without its 3MF has an empty file_path, which
+        resolves to the data directory itself."""
+        from backend.app.services.archive import extract_printable_objects_from_archive
+
+        assert extract_printable_objects_from_archive(tmp_path) == ({}, None)
+
+    def test_objects_come_back_with_their_positions(self, tmp_path):
+        import zipfile
+
+        from backend.app.services.archive import extract_printable_objects_from_archive
+
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <metadata key="index" value="1"/>
+            <object identify_id="101" name="left" skipped="false"/>
+            <object identify_id="102" name="right" skipped="false"/>
+          </plate>
+        </config>"""
+        path = tmp_path / "job.3mf"
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr("Metadata/slice_info.config", slice_info)
+
+        objects, _bbox = extract_printable_objects_from_archive(path, plate_number=1)
+
+        assert sorted(objects) == [101, 102]

+ 106 - 0
frontend/src/__tests__/pages/SkipObjectsButtonGate.test.tsx

@@ -0,0 +1,106 @@
+/**
+ * The Skip button must stay reachable when the object list is merely unknown.
+ *
+ * `printable_objects_count` is derived from an in-memory list that a Bambuddy
+ * restart wipes, so mid-print it can drop to 0 while the print carries on. The
+ * card read 0 as "nothing to skip" and disabled the button — and the button is
+ * what opens the modal whose fetch rebuilds the list, so the print never got it
+ * back. A running print always has at least one object, so 0 means unknown;
+ * exactly 1 is the real nothing-to-skip case.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { PrintersPage } from '../../pages/PrintersPage';
+
+const printer = {
+  id: 1,
+  name: 'H2C-1',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  model: 'H2C',
+  enabled: true,
+  is_active: true,
+  nozzle_diameter: 0.4,
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const printing = {
+  connected: true,
+  state: 'RUNNING',
+  // The card treats a print as active only when it can name it, so the fixture
+  // has to carry one for the Skip button to be in its printing state at all.
+  current_print: 'HULA_H2D_air_pad.gcode.3mf',
+  subtask_name: 'HULA_H2D_air_pad',
+  gcode_file: '/data/Metadata/plate_1.gcode',
+  progress: 42,
+  layer_num: 30,
+  total_layers: 184,
+  awaiting_plate_clear: false,
+  temperatures: { nozzle: 220, bed: 60, chamber: 30 },
+  remaining_time: 3600,
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+function serveStatus(status: Record<string, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+    http.get('/api/v1/printers/:id/print/objects', () =>
+      HttpResponse.json({ objects: [], total: 0, skipped_count: 0, is_printing: true, bbox_all: null }),
+    ),
+  );
+}
+
+// The whole live-status block waits on the status query, so find rather than get.
+const skipButton = () => screen.findByRole('button', { name: /skip objects/i });
+
+describe('skip objects button gate', () => {
+  beforeEach(() => {
+    localStorage.removeItem('printerCardSize');
+  });
+
+  it('stays enabled while printing when the object list is not loaded', async () => {
+    serveStatus({ ...printing, printable_objects_count: 0 });
+
+    render(<PrintersPage />);
+
+    expect(await skipButton()).toBeEnabled();
+  });
+
+  it('is disabled for a single-object print, where there is nothing to skip', async () => {
+    serveStatus({ ...printing, printable_objects_count: 1 });
+
+    render(<PrintersPage />);
+
+    const button = await skipButton();
+    expect(button).toBeDisabled();
+    expect(button).toHaveAttribute('title', 'Skip objects (requires 2+ objects)');
+  });
+
+  it('is enabled for the ordinary multi-object print', async () => {
+    serveStatus({ ...printing, printable_objects_count: 8 });
+
+    render(<PrintersPage />);
+
+    expect(await skipButton()).toBeEnabled();
+  });
+
+  it('is disabled when the printer is not printing', async () => {
+    serveStatus({ ...printing, state: 'IDLE', printable_objects_count: 0 });
+
+    render(<PrintersPage />);
+
+    const button = await skipButton();
+    expect(button).toBeDisabled();
+    expect(button).toHaveAttribute('title', 'Skip objects (only while printing)');
+  });
+});

+ 15 - 5
frontend/src/pages/PrintersPage.tsx

@@ -4258,13 +4258,23 @@ function PrinterCard({
                   const coverUrl = isActivePrint ? status.cover_url : showRetainedPrint ? retainedPrintJob.coverUrl : null;
                   const progress = isActivePrint ? (status.progress || 0) : showRetainedPrint ? 100 : 0;
 
+                  // A running print always has at least one object, so a count of
+                  // zero means "not loaded", not "nothing to skip" — after a
+                  // restart mid-print the list is empty until something rebuilds
+                  // it. Treating that as nothing-to-skip disabled the only
+                  // control that opens the modal, and the modal's own fetch is
+                  // what rebuilds the list, so the print could never get it back.
+                  // Exactly one object is the real nothing-to-skip case.
+                  const objectCount = status.printable_objects_count ?? 0;
+                  const canSkipObjects = isActivePrint && objectCount !== 1 && hasPermission('printers:control');
+
                   return (
                     <div className="p-2 bg-bambu-dark rounded-[10px] relative overflow-hidden">
                       <button
                         onClick={() => setShowSkipObjectsModal(true)}
-                        disabled={!isActivePrint || (status.printable_objects_count ?? 0) < 2 || !hasPermission('printers:control')}
+                        disabled={!canSkipObjects}
                         className={`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${
-                          isActivePrint && (status.printable_objects_count ?? 0) >= 2 && hasPermission('printers:control')
+                          canSkipObjects
                             ? 'text-bambu-gray hover:text-white hover:bg-white/10'
                             : 'text-bambu-gray/30 cursor-not-allowed'
                         }`}
@@ -4273,9 +4283,9 @@ function PrinterCard({
                             ? t('printers.permission.noControl')
                             : !isActivePrint
                               ? t('printers.skipObjects.onlyWhilePrinting')
-                              : (status.printable_objects_count ?? 0) >= 2
-                                ? t('printers.skipObjects.tooltip')
-                                : t('printers.skipObjects.requiresMultiple')
+                              : objectCount === 1
+                                ? t('printers.skipObjects.requiresMultiple')
+                                : t('printers.skipObjects.tooltip')
                         }
                       >
                         <SkipObjectsIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-CVKc5Khd.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CweAJSEj.js"></script>
+    <script type="module" crossorigin src="/assets/index-CVKc5Khd.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-kSJGQrMr.css">
   </head>
   <body>

Vissa filer visades inte eftersom för många filer har ändrats