Kaynağa Gözat

fix(backup): a Git restore brings back a print that was running at backup time as cancelled ("No status update received") instead of a print that failed on the next reconnect, and an archive this instance still has keeps its own status on overwrite; each spool's last-used time is backed up and restored

maziggy 2 gün önce
ebeveyn
işleme
5cda4c6913

+ 2 - 0
CHANGELOG.md

@@ -164,6 +164,8 @@ 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
+- **Git backup: a print running when the backup was made came back as a failed print** — Restored as still printing, the archive was closed on the next printer reconnect as if the print had just failed, with a failure notification and a Print Log entry. It is now restored as cancelled ("No status update received"), the way Bambuddy closes a print it lost track of; an archive this instance still has keeps its own status on overwrite.
+- **Git backup: spools came back as never used** — When each spool was last used was not in the backup. It is now backed up and restored (an older backup leaves the value already there).
 - **File manager: a slow folder listing showed as "printer unavailable"** — Listing a large folder (such as the camera recordings) on a slow SD card can outlast the wait, and was reported like an unreachable printer; every refresh also started another listing while the last one still ran. It now says the listing is taking too long, and a refresh waits for the listing already running.
 - **Printer files from the evening could be dated a year back** — File times on the printer carry no year, and the year was taken from the host's local clock while printers report UTC. On a host behind UTC, files written in the evening were dated a year back, so their camera recordings were not matched to their prints.
 - **SpoolBuddy: writing a sticker could break a Bambu spool's AMS link in Spoolman mode** — A Bambu spool created from the AMS is linked by its RFID tray ID; it was listed as untagged on the Write Tag page, and a sticker written to it replaced that link in Spoolman, so the next AMS sync created a duplicate spool. It is no longer listed as untagged, and on a Spoolman that does not also keep the tray ID as a native tag (before 0.27) the write is refused with the reason.

+ 3 - 0
backend/app/services/github_backup.py

@@ -812,6 +812,9 @@ class GitHubBackupService:
                 "category": s.category,
                 "low_stock_threshold_pct": s.low_stock_threshold_pct,
                 "storage_location": s.storage_location,
+                # When it was last printed with (LU-4): without it, every spool
+                # read as never used after a restore.
+                "last_used": str(s.last_used) if s.last_used else None,
                 # Last drying (#2863).
                 "last_dried_at": str(s.last_dried_at) if s.last_dried_at else None,
                 "last_dried_temp": s.last_dried_temp,

+ 21 - 2
backend/app/services/github_restore.py

@@ -1413,6 +1413,15 @@ class GitHubRestoreService:
             quantity = _restored_number(entry.get("quantity"), maximum=10_000, integer=True)
             if quantity is None:
                 quantity = 1
+            # A print running when the backup was made is long over: closed
+            # here the way the stale-archive cleanup closes one. Restored as
+            # running, the next reconnect closed it as a print that had just
+            # failed, with a failure notification and a Print Log row (ST/I7).
+            status = entry.get("status") or "completed"
+            failure_reason = entry.get("failure_reason")
+            backed_up_in_flight = status in ("printing", "paused")
+            if backed_up_in_flight:
+                status, failure_reason = "cancelled", "noStatusUpdate"
             fields = {
                 "print_name": entry.get("print_name"),
                 "print_time_seconds": numbers["print_time_seconds"],
@@ -1425,7 +1434,7 @@ class GitHubRestoreService:
                 "bed_temperature": entry.get("bed_temperature"),
                 "nozzle_temperature": entry.get("nozzle_temperature"),
                 "sliced_for_model": entry.get("sliced_for_model"),
-                "status": entry.get("status") or "completed",
+                "status": status,
                 "started_at": started_at,
                 "completed_at": _parse_dt(entry.get("completed_at")),
                 "makerworld_url": entry.get("makerworld_url"),
@@ -1435,7 +1444,7 @@ class GitHubRestoreService:
                 "tags": entry.get("tags"),
                 "notes": entry.get("notes"),
                 "cost": numbers["cost"],
-                "failure_reason": entry.get("failure_reason"),
+                "failure_reason": failure_reason,
                 "quantity": quantity,
                 "energy_kwh": numbers["energy_kwh"],
                 "energy_cost": numbers["energy_cost"],
@@ -1541,6 +1550,13 @@ class GitHubRestoreService:
                         "archivesUndeleted",
                         "Archive(s) deleted since the backup are visible again — overwrite was on",
                     )
+                if backed_up_in_flight:
+                    # The local row is that same print, still running or since
+                    # ended: it knows more about its outcome than the backup's
+                    # snapshot of it mid-way. Closing it here would end a live
+                    # print's archive, or overwrite a real result.
+                    for key in ("status", "failure_reason", "completed_at"):
+                        fields.pop(key, None)
                 for key, value in fields.items():
                     setattr(existing, key, value)
                 tally.restored += 1
@@ -1689,6 +1705,9 @@ class GitHubRestoreService:
             baseline = _restored_number(entry.get("weight_used_baseline"))
             if baseline is not None:
                 fields["weight_used_baseline"] = baseline
+            # When it was last used (LU-4), same late-field rule.
+            if "last_used" in entry:
+                fields["last_used"] = _parse_dt(entry.get("last_used"))
             # Last drying (#2863), same late-field rule.
             if "last_dried_at" in entry:
                 fields["last_dried_at"] = _parse_dt(entry.get("last_dried_at"))

+ 14 - 0
backend/tests/unit/test_github_backup_spool_fields.py

@@ -76,3 +76,17 @@ async def test_collects_the_total_consumed_reset_point(db_session):
     await GitHubBackupService()._collect_spools(db_session, files)
 
     assert files["spools/inventory.json"]["spools"][0]["weight_used_baseline"] == 300
+
+
+@pytest.mark.asyncio
+async def test_collects_when_the_spool_was_last_used(db_session):
+    """LU-4: without it, every spool read as never used after a restore."""
+    from datetime import datetime
+
+    db_session.add(Spool(material="PLA", last_used=datetime(2026, 10, 18, 9, 15)))
+    await db_session.commit()
+
+    files: dict = {}
+    await GitHubBackupService()._collect_spools(db_session, files)
+
+    assert files["spools/inventory.json"]["spools"][0]["last_used"] == "2026-10-18 09:15:00"

+ 67 - 0
backend/tests/unit/test_github_restore.py

@@ -888,6 +888,28 @@ class TestRestoreSpools:
         assert spool.last_dried_at == datetime(2026, 9, 1, 8, 0)
         assert spool.last_dried_temp == 55
 
+    @pytest.mark.asyncio
+    async def test_restores_when_the_spool_was_last_used(self, db_session):
+        """LU-4 -- written with str() like the drying date."""
+        tally = _CategoryTally()
+        entry = self._spool_entry(last_used="2026-10-18 09:15:00")
+
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        assert (await db_session.execute(select(Spool))).scalars().one().last_used == datetime(2026, 10, 18, 9, 15)
+
+    @pytest.mark.asyncio
+    async def test_older_backup_keeps_the_live_last_used(self, db_session):
+        db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", last_used=datetime(2026, 9, 1, 8, 0)))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
+        await db_session.commit()
+
+        assert (await db_session.execute(select(Spool))).scalars().one().last_used == datetime(2026, 9, 1, 8, 0)
+
     @pytest.mark.asyncio
     async def test_matches_existing_spool_by_tag_uid(self, db_session):
         db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
@@ -1402,6 +1424,51 @@ class TestRestoreArchives:
         entry.update(overrides)
         return entry
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("status", ["printing", "paused"])
+    async def test_a_print_running_when_backed_up_comes_back_closed(self, db_session, status):
+        """ST/I7: restored as running, the next reconnect closed it as a print
+        that had just failed -- a failure notification and a Print Log row.
+        It is closed here the way the stale-archive cleanup closes one."""
+        tally = _CategoryTally()
+
+        entry = self._archive_entry(status=status, completed_at=None)
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert (row.status, row.failure_reason) == ("cancelled", "noStatusUpdate")
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("local_status", "local_reason"), [("printing", None), ("completed", None), ("failed", "nozzleClog")]
+    )
+    async def test_an_overwrite_keeps_what_the_local_row_knows_of_a_print_backed_up_mid_way(
+        self, db_session, local_status, local_reason
+    ):
+        """The local row is the same print, still running or since ended: the
+        backup's snapshot of it in flight has nothing better to say."""
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                status=local_status,
+                failure_reason=local_reason,
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._archive_entry(status="printing", completed_at=None)
+        await _service()._restore_archives(db_session, {"archives": [entry]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert (row.status, row.failure_reason) == (local_status, local_reason)
+
     @pytest.mark.asyncio
     async def test_inserts_metadata_only_row_with_empty_file_path(self, db_session):
         """print_archives.file_path is NOT NULL but is not in the backup."""