Browse Source

SpoolBuddy: a written tag moves to the new spool (#3505)

- with the built-in inventory, write-result linked the tag without
  looking at other spools, so the spool that had it kept it too and a
  scan could resolve to that one
- every other spool holding the UID, archived ones included, now gives
  it up first, as the Spoolman branch already did
maziggy 1 day ago
parent
commit
4f11c64db3
3 changed files with 74 additions and 1 deletions
  1. 1 0
      CHANGELOG.md
  2. 11 1
      backend/app/api/routes/spoolbuddy.py
  3. 62 0
      backend/tests/integration/test_spoolbuddy.py

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 ### Fixed
+- **A tag written with SpoolBuddy moves to the new spool (#3505, reported by @pleasantone)** — With Bambuddy's own inventory, writing a tag that already belonged to another spool linked it to the new spool but left it on the old one too, so scanning it could open the old spool. The tag now moves: the spool that had it gives it up, as it already did with Spoolman.
 - **SpoolBuddy checks a tag write instead of always reporting success (#3076, reported by @jinjiangz, gaps found by @pleasantone)** — The PN5180 can't see a tag's answer to a write, so **Write Tag** reported success for every page it sent. SpoolBuddy now reads the written data back and fails the write if the tag holds something else, naming the page. If the tag doesn't answer the read-back, the write still counts, reported as not verified. A tag too small for the data is refused before anything is written, and so is a tag that isn't NDEF-formatted, such as a blank MIFARE Ultralight, which looks like an NTAG to the reader. Before, a write past its end was reported as successful. When the write's result couldn't reach Bambuddy straight away, the next heartbeat brought the same write back and SpoolBuddy ran it a second time, on whatever tag was then on the reader. It now waits until the result is through.
 - **HMS notifications are no longer sent again after a restart (#3502, reported by @pleasantone)** — The fix for #3486 remembers which printer errors were already notified, in a table that was never created: startup did not load its model, so every start logged "no such table: hms_notified_faults" and the errors were sent again. The table is now created on the next start.
 - **A backup from Windows restored on Linux or macOS finds its files (#3501, reported by @Tech0verlord)** — A Windows install stores the location of each archive, library file and print log entry with backslashes, which Linux and macOS read as part of the file name. After restoring a Windows backup on Docker or another Linux install, every archive showed no thumbnail and refused its 3D preview and reprint with "Archive data unavailable". The restore now turns those backslashes into slashes. It runs only during a restore, does nothing on Windows, and leaves a path alone when a file with that exact name exists. An install already restored from a Windows backup is fixed by restoring the backup again after updating.

+ 11 - 1
backend/app/api/routes/spoolbuddy.py

@@ -1134,7 +1134,17 @@ async def nfc_write_result(
                 )
                 return {"status": "ok", "linked": False, "message": "Spool not found"}
 
-            spool.tag_uid = req.tag_uid.upper()
+            # The tag now holds this spool's data, so it moves here from any
+            # other spool that had it, as in the Spoolman branch above.
+            # Otherwise two spools share the UID and a scan finds the old one
+            # (#3505). Archived spools give it up as well, as link-tag does.
+            tag_uid = req.tag_uid.upper()
+            previous = await db.execute(select(Spool).where(func.upper(Spool.tag_uid) == tag_uid, Spool.id != spool.id))
+            for holder in previous.scalars().all():
+                holder.tag_uid = None
+                logger.info("Tag %s moved from spool %d to spool %d", tag_uid, holder.id, spool.id)
+
+            spool.tag_uid = tag_uid
             spool.tag_type = "ntag"
             spool.data_origin = "opentag3d"
             spool.encode_time = datetime.now(timezone.utc)

+ 62 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -960,6 +960,68 @@ class TestWriteTagEndpoints:
         assert spool_data["data_origin"] == "opentag3d"
         assert spool_data["encode_time"] is not None
 
+    async def _write_result(self, async_client: AsyncClient, device_id: str, spool_id: int, tag_uid: str):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/write-result",
+                json={"device_id": device_id, "spool_id": spool_id, "tag_uid": tag_uid, "success": True},
+            )
+        assert resp.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_written_tag_moves_from_the_spool_that_had_it(
+        self, async_client: AsyncClient, db_session: AsyncSession, device_factory, spool_factory
+    ):
+        """#3505: the tag now holds spool B's data, so spool A gives it up;
+        before, both kept the UID and a scan found A."""
+        device = await device_factory(device_id="sb-move", pending_command="write_tag")
+        spool_a = await spool_factory(tag_uid="04AABB11223344", tag_type="ntag")
+        spool_b = await spool_factory(tag_uid=None)
+
+        # The daemon reports the UID in any case; the stored one is upper case.
+        await self._write_result(async_client, device.device_id, spool_b.id, "04aabb11223344")
+
+        await db_session.refresh(spool_a)
+        await db_session.refresh(spool_b)
+        assert spool_a.tag_uid is None
+        assert spool_b.tag_uid == "04AABB11223344"
+        # A scan of the tag now finds B.
+        lookup = await async_client.get("/api/v1/inventory/spools/by-tag", params={"tag_uid": "04AABB11223344"})
+        assert lookup.status_code == 200
+        assert lookup.json()["id"] == spool_b.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_archived_spool_gives_up_the_written_tag(
+        self, async_client: AsyncClient, db_session: AsyncSession, device_factory, spool_factory
+    ):
+        device = await device_factory(device_id="sb-move-arch", pending_command="write_tag")
+        archived = await spool_factory(tag_uid="04AABB11223355", archived_at=datetime.now(timezone.utc))
+        spool = await spool_factory(tag_uid=None)
+
+        await self._write_result(async_client, device.device_id, spool.id, "04AABB11223355")
+
+        await db_session.refresh(archived)
+        assert archived.tag_uid is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_writing_a_spool_its_own_tag_again_keeps_it(
+        self, async_client: AsyncClient, db_session: AsyncSession, device_factory, spool_factory, caplog
+    ):
+        device = await device_factory(device_id="sb-rewrite", pending_command="write_tag")
+        spool = await spool_factory(tag_uid="04AABB11223366", tag_type="ntag")
+
+        with caplog.at_level("INFO", logger="backend.app.api.routes.spoolbuddy"):
+            await self._write_result(async_client, device.device_id, spool.id, "04AABB11223366")
+
+        await db_session.refresh(spool)
+        assert spool.tag_uid == "04AABB11223366"
+        # Nothing moved: the spool is not reported as giving the tag to itself.
+        assert "moved from spool" not in caplog.text
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_write_result_failure_broadcasts_error(