ソースを参照

Use Spoolman 0.27's native tags in Spoolman mode (issue #3168) (#3252)

FormFollowsFunction 3 日 前
親
コミット
a8965b0e9f
36 ファイル変更、1904 行追加、18 行削除
  1. 10 0
      backend/app/api/routes/_spoolman_helpers.py
  2. 57 6
      backend/app/api/routes/spoolbuddy.py
  3. 32 1
      backend/app/api/routes/spoolman.py
  4. 126 5
      backend/app/api/routes/spoolman_inventory.py
  5. 206 4
      backend/app/services/spoolman.py
  6. 13 0
      backend/app/services/spoolman_tracking.py
  7. 179 0
      backend/tests/_fixtures/spoolman_tags.py
  8. 3 0
      backend/tests/integration/test_ams_slot_material_2902.py
  9. 18 0
      backend/tests/integration/test_spoolbuddy.py
  10. 3 0
      backend/tests/integration/test_spoolbuddy_color_name_3090.py
  11. 3 0
      backend/tests/integration/test_spoolbuddy_spool_colours_3033.py
  12. 9 0
      backend/tests/integration/test_spoolbuddy_spoolman_nfc.py
  13. 6 0
      backend/tests/integration/test_spoolman_api.py
  14. 15 0
      backend/tests/integration/test_spoolman_inventory_api.py
  15. 522 0
      backend/tests/integration/test_spoolman_native_tags_3168.py
  16. 2 0
      backend/tests/integration/test_spoolman_status_2903.py
  17. 4 0
      backend/tests/unit/services/test_spoolman_extra_field_registration_2903.py
  18. 296 0
      backend/tests/unit/services/test_spoolman_native_tags_3168.py
  19. 94 0
      frontend/src/__tests__/components/SpoolmanSettings.test.tsx
  20. 13 0
      frontend/src/api/client.ts
  21. 98 2
      frontend/src/components/SpoolmanSettings.tsx
  22. 13 0
      frontend/src/i18n/locales/de.ts
  23. 13 0
      frontend/src/i18n/locales/en.ts
  24. 13 0
      frontend/src/i18n/locales/es.ts
  25. 13 0
      frontend/src/i18n/locales/fr.ts
  26. 13 0
      frontend/src/i18n/locales/it.ts
  27. 13 0
      frontend/src/i18n/locales/ja.ts
  28. 13 0
      frontend/src/i18n/locales/ko.ts
  29. 13 0
      frontend/src/i18n/locales/nl.ts
  30. 13 0
      frontend/src/i18n/locales/pt-BR.ts
  31. 13 0
      frontend/src/i18n/locales/ru.ts
  32. 13 0
      frontend/src/i18n/locales/sv.ts
  33. 13 0
      frontend/src/i18n/locales/tr.ts
  34. 13 0
      frontend/src/i18n/locales/uk.ts
  35. 13 0
      frontend/src/i18n/locales/zh-CN.ts
  36. 13 0
      frontend/src/i18n/locales/zh-TW.ts

+ 10 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -376,6 +376,16 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
     tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
     tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
+    # Spoolman 0.27+ native tags fill whichever of the two extra.tag left empty,
+    # so a spool linked there shows as tagged here as well.
+    for native in spool.get("tags") or []:
+        uid = str(native.get("uid") or "").upper()
+        if not _TAG_HEX_RE.match(uid):
+            continue
+        if len(uid) == 32:
+            tray_uuid = tray_uuid or uid
+        elif 8 <= len(uid) <= 30:
+            tag_uid = tag_uid or uid
 
     # Subtype = filament name with material prefix stripped
     material: str = (filament.get("material") or "").strip()

+ 57 - 6
backend/app/api/routes/spoolbuddy.py

@@ -466,14 +466,33 @@ async def nfc_tag_scanned(
     if client is not None:
         # Spoolman mode — exclusive lookup, no local-DB fallback.
         try:
-            cached_spools = await client.get_spools()
             sm_spool: dict | None = None
-            if tray_uuid:
-                sm_spool = await client.find_spool_by_tag(tray_uuid, cached_spools=cached_spools)
-            if sm_spool is None and req.tag_uid:
-                sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
+            native = await client.has_tag_api()
+            cached_spools: list[dict] | None = None
+            # The tray UUID is settled before the tag UID, as it always was: a spool
+            # that carries the UUID only in extra.tag still wins over one holding the
+            # chip UID natively. Spoolman 0.27+ answers each identifier with one exact
+            # query; the whole inventory is loaded only when that misses.
+            for uid in (tray_uuid, req.tag_uid):
+                if not uid or sm_spool is not None:
+                    continue
+                if native:
+                    try:
+                        sm_spool = await client.find_spool_by_native_tag(uid.upper())
+                    except SpoolmanClientError as exc:
+                        logger.warning("Native tag lookup refused for %s, using extra.tag: %s", uid, exc)
+                if sm_spool is None:
+                    if cached_spools is None:
+                        cached_spools = await client.get_spools()
+                    sm_spool = await client.find_spool_by_tag(uid, cached_spools=cached_spools)
 
             if sm_spool is not None:
+                # What this scan read becomes a native tag of the spool: a spool found
+                # through extra.tag moves over on its first scan, and a Bambu spool
+                # collects the chip UID of each side as it is read.
+                await client.add_native_tags(
+                    sm_spool, [tray_uuid, req.tag_uid], "bambu" if tray_uuid else None, retry_refused=True
+                )
                 mapped = _map_spoolman_spool(sm_spool)
                 await ws_manager.broadcast(
                     {
@@ -801,7 +820,39 @@ async def nfc_write_result(
                         req.spool_id,
                     )
 
-                await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
+                # Spoolman 0.27+: the same tag as a native one. It was just written onto
+                # this spool, so a previous holder gives it up here too. Linked before
+                # extra.tag, as the step Spoolman can refuse; a failed extra.tag write
+                # takes back what was added.
+                uid = req.tag_uid.upper()
+                added_native = False
+                if await sm_client.has_tag_api():
+                    before = await sm_client.get_spool(req.spool_id)
+                    had = any(t.get("uid") == uid for t in before.get("tags") or [])
+                    # A spool that already holds the tag has nothing to link or move.
+                    if not had:
+                        holder = await sm_client.link_native_tag(req.spool_id, uid)
+                        if holder is not None and holder > 0:
+                            previous = holder
+                            await sm_client.unlink_native_tag(previous, uid)
+                            holder = await sm_client.link_native_tag(req.spool_id, uid)
+                            logger.info(
+                                "Spoolman: native tag %s moved from spool %d to %d", uid, previous, req.spool_id
+                            )
+                        if holder is not None:
+                            logger.warning("Native tag %s belongs to a filament or location, not linked", uid)
+                        added_native = holder is None
+                try:
+                    await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
+                except Exception:
+                    if added_native:
+                        try:
+                            await sm_client.unlink_native_tag(req.spool_id, uid)
+                        except (SpoolmanClientError, SpoolmanUnavailableError) as undo_exc:
+                            logger.warning(
+                                "Could not take back native tag %s from spool %d: %s", uid, req.spool_id, undo_exc
+                            )
+                    raise
                 logger.info(
                     "Spoolman tag written and linked: spool %d -> tag %s",
                     req.spool_id,

+ 32 - 1
backend/app/api/routes/spoolman.py

@@ -57,6 +57,8 @@ class SpoolmanStatus(BaseModel):
     enabled: bool
     connected: bool
     url: str | None
+    # Spoolman 0.27+ links tags natively; the settings offer the migration then.
+    native_tags: bool = False
 
 
 class SkippedSpool(BaseModel):
@@ -133,6 +135,7 @@ async def get_spoolman_status(
     enabled, url = sm["enabled"], sm["url"]
 
     connected = False
+    native_tags = False
     if enabled and url:
         client = await get_spoolman_client()
         if not client or client.base_url != url.rstrip("/"):
@@ -151,11 +154,14 @@ async def get_spoolman_status(
                 client = None
         if client:
             connected = await client.health_check()
+            # Asked once per client and cached, so the 30-second poll costs nothing more.
+            native_tags = connected and await client.has_tag_api()
 
     return SpoolmanStatus(
         enabled=enabled,
         connected=connected,
         url=url if url else None,
+        native_tags=native_tags,
     )
 
 
@@ -885,7 +891,29 @@ async def link_spool(
         printer_context = (request.printer_id, request.ams_id, request.tray_id)
 
     try:
-        await client.merge_spool_extra(spool_id, {"tag": json.dumps(spool_tag)})
+        # Spoolman 0.27+: a tray UUID also becomes a native tag of the spool. The
+        # 16-character values this route takes are either a slot's fallback ID or
+        # the AMS's padded chip UID - neither is what a reader sees, so they stay in
+        # extra.tag only; the AMS sync adds the real chip UID when it reads the tag.
+        # The native link goes first, as the step Spoolman can refuse, so a refusal
+        # leaves extra.tag untouched; a failed extra.tag write takes back what was added.
+        added_native = False
+        if len(spool_tag) == 32 and await client.has_tag_api():
+            before = await client.get_spool(spool_id)
+            had = any(t.get("uid") == spool_tag for t in before.get("tags") or [])
+            holder = await client.claim_native_tag(spool_id, spool_tag, "bambu")
+            if holder is not None:
+                logger.warning("Native tag %s belongs to spool %s, left there", spool_tag, holder)
+            added_native = holder is None and not had
+        try:
+            await client.merge_spool_extra(spool_id, {"tag": json.dumps(spool_tag)})
+        except Exception:
+            if added_native:
+                try:
+                    await client.unlink_native_tag(spool_id, spool_tag)
+                except (SpoolmanClientError, SpoolmanUnavailableError) as exc:
+                    logger.warning("Could not take back native tag %s from spool %s: %s", spool_tag, spool_id, exc)
+            raise
     except SpoolmanNotFoundError:
         raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
     except SpoolmanClientError:
@@ -1206,6 +1234,9 @@ async def unlink_spool(
     # deadlock (asyncio.Lock is not reentrant).
     try:
         await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
+        # And the native tags, so an unlinked spool is found by none of them.
+        if await client.has_tag_api():
+            await client.unlink_all_native_tags(await client.get_spool(spool_id))
     except SpoolmanNotFoundError:
         raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
     except SpoolmanClientError:

+ 126 - 5
backend/app/api/routes/spoolman_inventory.py

@@ -73,7 +73,7 @@ from backend.app.services.spoolman import (
     get_spoolman_client,
     init_spoolman_client,
 )
-from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
+from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot, is_slot_fallback_tag
 from backend.app.services.tag_conflict import tag_already_linked
 from backend.app.utils.color_utils import spoolman_color_hex
 from backend.app.utils.filament_ids import (
@@ -953,6 +953,15 @@ async def update_spool(
                 spool_weight=data.core_weight,
             )
 
+        # Spoolman 0.27+: the native tags go as well, or the spool would still be
+        # found by every one of them after "Clear RFID Tag". Only once the update
+        # went through, so a failed one leaves the spool as it was; a failure here
+        # is reported, and clearing again removes what is left.
+        if tag_nulled:
+            async with _translate_spoolman_errors():
+                await client.unlink_all_native_tags(fresh)
+            updated = {**updated, "tags": []}
+
     # Persist BambuStudio slicer preset AND color_name under spool.extra.
     # Spoolman has no native fields for these — color_name was confirmed
     # absent from the FilamentUpdateParameters schema in 0.23.1 (#1357), so
@@ -1336,16 +1345,128 @@ async def link_tag_to_spoolman_spool(
         # Re-fetch inside the lock so cur_extra reflects any concurrent update.
         async with _translate_spoolman_errors():
             current = await client.get_spool(spool_id)
-        cur_extra = dict(current.get("extra") or {})
-        cur_extra["tag"] = tag_json
-        async with _translate_spoolman_errors():
-            updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
+
+        # Spoolman 0.27+: both identifiers become native tags of the spool, added to
+        # what it already carries rather than replacing it. Spoolman refuses a UID
+        # another spool holds with a 409 naming that spool, the same answer as above.
+        # Whatever this request added is taken back if anything after it fails, a
+        # refused second identifier or the extra.tag write, so the spool is left as
+        # it was found.
+        added: list[str] = []
+        try:
+            if await client.has_tag_api():
+                had = {t.get("uid") for t in current.get("tags") or []}
+                for field, uid in (("tray_uuid", data.tray_uuid), ("tag_uid", data.tag_uid)):
+                    if not uid:
+                        continue
+                    async with _translate_spoolman_errors():
+                        holder = await client.claim_native_tag(
+                            spool_id, uid.upper(), "bambu" if data.tray_uuid else None
+                        )
+                    if holder is not None and holder != spool_id:
+                        if holder < 0:
+                            # A filament or a location holds it: no spool to name or move it from.
+                            raise HTTPException(
+                                status_code=409,
+                                detail={
+                                    "code": "tag_linked_elsewhere",
+                                    "message": f"{field} is linked to a filament or location in Spoolman",
+                                    "field": field,
+                                },
+                            )
+                        raise tag_already_linked(field, holder)
+                    if uid.upper() not in had:
+                        added.append(uid.upper())
+
+            cur_extra = dict(current.get("extra") or {})
+            cur_extra["tag"] = tag_json
+            async with _translate_spoolman_errors():
+                updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
+        except HTTPException:
+            for uid_added in added:
+                try:
+                    await client.unlink_native_tag(spool_id, uid_added)
+                except (SpoolmanClientError, SpoolmanUnavailableError) as exc:
+                    logger.warning("Could not take back native tag %s from spool %s: %s", uid_added, spool_id, exc)
+            raise
 
     logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
     await ws_manager.broadcast({"type": "inventory_changed"})
     return _map_spoolman_spool(updated)
 
 
+_HEX_TAG_RE = re.compile(r"^[0-9A-F]{8,64}$")
+
+
+@router.post("/tags/migrate")
+async def migrate_tags_to_native(
+    dry_run: bool = Query(True, description="Only count what would move, change nothing"),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Move tags from Spoolman's extra.tag into its native tags (Spoolman 0.27+).
+
+    extra.tag itself is left as it is, so older readers keep working and a
+    downgrade loses nothing. A slot's fallback ID is not a physical tag and
+    stays behind. Archived spools are skipped, and a tag an archived spool still
+    holds moves to the active one. A tag Spoolman holds on another active spool
+    is reported as a conflict for the user to settle, never moved by guessing.
+    """
+    client = await _get_client(db)
+    if not await client.has_tag_api():
+        raise HTTPException(
+            status_code=409,
+            detail={"code": "spoolman_without_tags", "message": "Spoolman 0.27 or later is required"},
+        )
+    serials = (await db.execute(select(Printer.serial_number))).scalars().all()
+    async with _translate_spoolman_errors():
+        spools = await client.get_all_spools(allow_archived=True)
+
+    # Who holds which native tag, from the listing: the dry run sees the same
+    # conflicts the real one would, and two spools sharing one extra.tag are
+    # caught on the second, with no request spent on either. Archived spools are
+    # listed only for this: nothing looks an archived spool up, so they get no
+    # tags, and one that still holds a tag gives it up to the active spool.
+    archived = {spool["id"] for spool in spools if spool.get("archived")}
+    native_holder = {t.get("uid"): spool["id"] for spool in spools for t in spool.get("tags") or []}
+    report: dict = {"dry_run": dry_run, "moved": [], "already": 0, "slot_ids": 0, "conflicts": []}
+    for spool in spools:
+        if spool["id"] in archived:
+            continue
+        tag = _extra_tag(spool)
+        if not _HEX_TAG_RE.match(tag) or set(tag) == {"0"}:
+            continue
+        if is_slot_fallback_tag(tag, serials):
+            report["slot_ids"] += 1
+            continue
+        holder = native_holder.get(tag)
+        if holder == spool["id"]:
+            report["already"] += 1
+            continue
+        if holder in archived:
+            holder = None
+        if holder is None and not dry_run:
+            async with _translate_spoolman_errors():
+                holder = await client.claim_native_tag(spool["id"], tag, "bambu" if len(tag) == 32 else None)
+        if holder is not None:
+            report["conflicts"].append({"spool_id": spool["id"], "tag": tag, "holder": holder})
+            continue
+        native_holder[tag] = spool["id"]
+        report["moved"].append(spool["id"])
+
+    logger.info(
+        "Native tag migration (dry_run=%s): %d moved, %d already, %d slot ids, %d conflicts",
+        dry_run,
+        len(report["moved"]),
+        report["already"],
+        report["slot_ids"],
+        len(report["conflicts"]),
+    )
+    if not dry_run and report["moved"]:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return report
+
+
 @router.get("/slot-assignments/all", response_model=list[SpoolmanSlotAssignmentEnriched])
 async def get_all_spoolman_slot_assignments(
     printer_id: int | None = Query(None, gt=0),

+ 206 - 4
backend/app/services/spoolman.py

@@ -4,6 +4,7 @@ import asyncio
 import json
 import logging
 import math
+import time
 import weakref
 from dataclasses import dataclass
 from datetime import datetime, timezone
@@ -19,6 +20,15 @@ logger = logging.getLogger(__name__)
 
 BAMBU_RFID_TAG_LENGTH = 32
 
+# How long a Spoolman without native tags is believed before it is asked again,
+# so upgrading Spoolman to 0.27 needs no Bambuddy restart.
+TAG_API_RECHECK_SECONDS = 600
+
+# How long a native tag Spoolman refused for a spool is left alone. The AMS sync
+# would otherwise ask again on every AMS update; this way a conflict settled in
+# Spoolman is still picked up without a restart.
+TAG_REFUSAL_RETRY_SECONDS = 600
+
 # Spool.extra key holding the consumed-counter baseline. Defined here rather
 # than imported from the routes package so the client does not depend on it;
 # _spoolman_helpers declares the same name for the read side (#2906), and
@@ -130,6 +140,12 @@ class SpoolmanClient:
         # whole listing before each one.
         self._extra_fields_listed = False
         self._ensure_extra_lock = asyncio.Lock()
+        # Whether this server links tags natively (Spoolman 0.27+), None until asked.
+        # Per instance like the field cache above: a client for another URL asks again.
+        self._tag_api: bool | None = None
+        self._tag_api_asked_at = 0.0
+        # (spool id, UID) -> when Spoolman last refused that native tag for that spool.
+        self._refused_tags: dict[tuple[int, str], float] = {}
 
     async def _get_client(self) -> httpx.AsyncClient:
         """Get or create the HTTP client with connection pooling limits."""
@@ -916,12 +932,180 @@ class SpoolmanClient:
             logger.error("Failed to record spool usage in Spoolman: %s", e)
             raise SpoolmanUnavailableError(f"Failed to record usage for spool {spool_id}") from e
 
+    async def has_tag_api(self) -> bool:
+        """Whether this Spoolman links tags natively (0.27+); older servers answer 404.
+
+        A yes is kept for the client's lifetime. A no is asked again after
+        TAG_API_RECHECK_SECONDS, so a Spoolman upgraded underneath is picked up.
+        """
+        stale = self._tag_api is False and time.monotonic() - self._tag_api_asked_at > TAG_API_RECHECK_SECONDS
+        if self._tag_api is None or stale:
+            try:
+                client = await self._get_client()
+                response = await client.get(f"{self.api_url}/tag/reader")
+            except httpx.HTTPError as e:
+                # Not cached: a Spoolman that is briefly down is asked again next time.
+                logger.debug("Spoolman tag API probe failed: %s", e)
+                return False
+            self._tag_api = response.status_code == 200
+            self._tag_api_asked_at = time.monotonic()
+            logger.info("Spoolman native tags: %s", "available" if self._tag_api else "not available")
+        return self._tag_api
+
+    async def _tag_request(self, method: str, path: str, **kwargs) -> httpx.Response:
+        """A request to Spoolman's tag endpoints, with errors as the typed exceptions the routes translate.
+
+        409 and 400 come back as responses: they are answers the callers act on.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.request(method, f"{self.api_url}{path}", **kwargs)
+        except httpx.HTTPError as e:
+            raise SpoolmanUnavailableError(f"Cannot reach Spoolman for {method} {path}") from e
+        if response.status_code in (400, 409) or response.status_code < 400:
+            return response
+        if response.status_code == 404:
+            raise SpoolmanNotFoundError(f"Spoolman answered 404 for {method} {path}")
+        if response.status_code < 500:
+            raise SpoolmanClientError(
+                f"Spoolman rejected {method} {path} (HTTP {response.status_code})",
+                response.status_code,
+                response.text[:500],
+            )
+        raise SpoolmanUnavailableError(f"Spoolman failed {method} {path} (HTTP {response.status_code})")
+
+    async def find_spool_by_native_tag(self, uid: str) -> dict | None:
+        """Return the spool a native tag is linked to, asked on the server side, or None."""
+        response = await self._tag_request("GET", "/spool", params={"tag": uid})
+        if response.status_code == 400:
+            return None  # not a hex UID, so no spool can hold it
+        # Only a spool that really carries the tag counts: a server that does not
+        # know the parameter ignores it and answers every spool.
+        return next(
+            (s for s in response.json() if any(t.get("uid") == uid for t in s.get("tags") or [])),
+            None,
+        )
+
+    async def link_native_tag(self, spool_id: int, uid: str, tag_format: str | None = None) -> int | None:
+        """Link a native tag to a spool; None on success, the holder's spool id on a conflict.
+
+        A conflict whose holder is not a spool (a filament or a location) answers -1.
+        """
+        body: dict = {"uid": uid}
+        if tag_format:
+            body["format"] = tag_format
+        response = await self._tag_request("POST", f"/spool/{spool_id}/tag", json=body)
+        if response.status_code == 409:
+            holder = response.json().get("spool_id")
+            return holder if isinstance(holder, int) else -1
+        if response.status_code == 400:
+            raise SpoolmanClientError(f"Spoolman refused tag {uid!r}", 400, response.text[:500])
+        return None
+
+    async def claim_native_tag(self, spool_id: int, uid: str, tag_format: str | None = None) -> int | None:
+        """Link a native tag to a spool, taking it from an archived spool that still holds it.
+
+        Spoolman keeps a tag unique across archived spools too, while Bambuddy never
+        looks at those: a spool is archived once it is used up or replaced, so the tag
+        it still holds belongs to the active spool now. A tag held by an active spool,
+        a filament or a location is refused as with link_native_tag.
+        """
+        holder = await self.link_native_tag(spool_id, uid, tag_format)
+        if holder is None or holder < 0:
+            return holder
+        try:
+            held_by = await self.get_spool(holder)
+        except SpoolmanNotFoundError:
+            return holder
+        if not held_by.get("archived"):
+            return holder
+        await self.unlink_native_tag(holder, uid)
+        logger.info("Native tag %s moved from archived spool %s to spool %s", uid, holder, spool_id)
+        return await self.link_native_tag(spool_id, uid, tag_format)
+
+    async def unlink_native_tag(self, spool_id: int, uid: str) -> None:
+        """Remove a native tag from a spool; a tag the spool does not hold is not an error."""
+        try:
+            await self._tag_request("DELETE", f"/spool/{spool_id}/tag/{uid}")
+        except SpoolmanNotFoundError:
+            pass
+
+    async def unlink_all_native_tags(self, spool: dict) -> None:
+        """Remove every native tag a spool carries. No-op on a server without the tag API."""
+        if not await self.has_tag_api():
+            return
+        for tag in spool.get("tags") or []:
+            uid = tag.get("uid")
+            if uid:
+                await self.unlink_native_tag(int(spool["id"]), uid)
+
+    async def add_native_tags(
+        self,
+        spool: dict,
+        uids: list[str | None],
+        tag_format: str | None = None,
+        *,
+        retry_refused: bool = False,
+    ) -> int:
+        """Link the given UIDs to the spool as native tags, skipping those it already has.
+
+        Best-effort: a UID another spool holds, or a failed request, is logged and
+        skipped, so a caller never loses its main result over this. A refused UID is
+        not asked for again for TAG_REFUSAL_RETRY_SECONDS, and warned about once:
+        the AMS sync calls this on every AMS update. A scan passes retry_refused,
+        since someone is standing at the spool and may just have settled the
+        conflict in Spoolman. Returns how many tags were added.
+        """
+        if not await self.has_tag_api():
+            return 0
+        spool_id = int(spool["id"])
+        have = {t.get("uid") for t in spool.get("tags") or []}
+        wanted = {u.strip('"').upper() for u in uids if u and u.strip('"').strip("0")}
+        now = time.monotonic()
+        added = 0
+        for uid in sorted(wanted - have):
+            refused_at = self._refused_tags.get((spool_id, uid))
+            if not retry_refused and refused_at is not None and now - refused_at < TAG_REFUSAL_RETRY_SECONDS:
+                continue
+            try:
+                holder = await self.claim_native_tag(spool_id, uid, tag_format)
+            except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as e:
+                logger.warning("Could not add native tag %s to spool %s: %s", uid, spool_id, e)
+                continue
+            if holder is None:
+                added += 1
+                self._refused_tags.pop((spool_id, uid), None)
+                logger.info("Added native tag %s to Spoolman spool %s", uid, spool_id)
+                continue
+            log = logger.debug if refused_at is not None else logger.warning
+            where = f"spool {holder}" if holder > 0 else "a filament or location"
+            log("Native tag %s belongs to %s, not added to spool %s", uid, where, spool_id)
+            self._refused_tags[(spool_id, uid)] = now
+        return added
+
     async def find_spool_by_tag(self, tag_uid: str, cached_spools: list[dict] | None = None) -> dict | None:
-        """Return the spool matching the given RFID tag UID, or None if not found."""
+        """Return the spool matching the given RFID tag UID, or None if not found.
+
+        Native tags (Spoolman 0.27+) are asked first: out of the cached list when there
+        is one, otherwise with one server-side query instead of loading every spool.
+        extra.tag stays the fallback for spools that were linked before.
+        """
+        search_tag = tag_uid.strip('"').upper()
+        if cached_spools is not None:
+            for spool in cached_spools:
+                if any(t.get("uid") == search_tag for t in spool.get("tags") or []):
+                    return spool
+        elif await self.has_tag_api():
+            try:
+                spool = await self.find_spool_by_native_tag(search_tag)
+            except (SpoolmanClientError, SpoolmanUnavailableError) as e:
+                logger.warning("Native tag lookup failed, falling back to extra.tag: %s", e)
+                spool = None
+            if spool is not None:
+                return spool
+
         # Use cached spools if provided, otherwise fetch from API
         spools = cached_spools if cached_spools is not None else await self.get_spools()
-        # Normalize tag_uid for comparison (uppercase, strip quotes)
-        search_tag = tag_uid.strip('"').upper()
 
         for spool in spools:
             extra = spool.get("extra", {})
@@ -1292,8 +1476,24 @@ class SpoolmanClient:
         if spool_tag:
             # Primary path: match by RFID tag
             existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
+            is_bambu = self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx)
+            tag_format = "bambu" if is_bambu else None
+            # The AMS reports a Bambu chip's 4-byte UID padded to 8 bytes
+            # ("D3E68F32" arrives as "D3E68F3200000100"). A native tag is the UID any
+            # reader sees, so only the chip's own 4 bytes go there.
+            chip_uid = tray.tag_uid[:8] if is_bambu and tray.tag_uid and len(tray.tag_uid) == 16 else tray.tag_uid
+            # A spool linked by its chip UID alone - one side of a Bambu spool read by
+            # SpoolBuddy, say - is found by that too, not only by the tray UUID. A
+            # reader stores the chip's own UID, a link from the AMS the padded one.
+            if tray.tag_uid and tray.tag_uid != zero_tag:
+                for uid in dict.fromkeys((chip_uid, tray.tag_uid)):
+                    if existing is None and uid != spool_tag:
+                        existing = await self.find_spool_by_tag(uid, cached_spools=cached_spools)
             if existing:
                 logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
+                # Native tags collect what the AMS reads: the tray UUID, and the chip
+                # UID of whichever side faces the reader, so both sides end up linked.
+                await self.add_native_tags(existing, [tray.tray_uuid, chip_uid], tag_format)
                 return await self.update_spool(
                     spool_id=existing["id"],
                     remaining_weight=None if disable_weight_sync else remaining,
@@ -1345,12 +1545,14 @@ class SpoolmanClient:
             # _ensure_extra_fields registers the key from the payload (#2903).
             if catalog_color is not None:
                 extra["bambu_color_name"] = json.dumps(catalog_color.name)
-            return await self.create_spool(
+            created = await self.create_spool(
                 filament_id=filament_id,
                 remaining_weight=remaining,
                 comment="Created by Bambuddy",
                 extra=extra,
             )
+            await self.add_native_tags(created, [tray.tray_uuid, chip_uid], tag_format)
+            return created
 
         # No-RFID fallback: use the spool ID resolved from the local slot-assignment table.
         # Never create new spools without a tag to avoid duplicates.

+ 13 - 0
backend/app/services/spoolman_tracking.py

@@ -102,6 +102,19 @@ def get_fallback_spool_tag_for_slot(printer_serial: str, ams_id: int, tray_id: i
     return f"{_hash_serial_to_hex32(printer_serial)}{_to_fixed_hex(ams_id, 4)}{_to_fixed_hex(tray_id, 4)}"
 
 
+def is_slot_fallback_tag(tag: str, printer_serials) -> bool:
+    """Whether a tag is an AMS slot's generated fallback ID rather than a physical tag.
+
+    Fallback IDs are 16 hex characters that start with the printer serial's hash
+    (see get_fallback_spool_tag_for_slot). They identify a slot, not a spool, and
+    have no place among Spoolman's native tags.
+    """
+    tag = (tag or "").strip('"').upper()
+    if len(tag) != 16:
+        return False
+    return any(tag[:8] == _hash_serial_to_hex32(s) for s in printer_serials if s)
+
+
 def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id: int | None = None) -> str:
     """Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).
 

+ 179 - 0
backend/tests/_fixtures/spoolman_tags.py

@@ -0,0 +1,179 @@
+"""A Spoolman that links tags natively the way 0.27 does, or not at all like older servers.
+
+The rules modelled here are the server's own (``spoolman/api/v1/spool.py`` and
+``spoolman/database/tag.py`` in Spoolman 0.27):
+
+- ``GET /api/v1/tag/reader`` answers 200 on 0.27; an older server has no such route.
+- a UID is stored without separators and upper-cased, and must be hex, else 400
+- a UID identifies exactly one spool, filament or location, archived spools
+  included: linking one that something else holds answers 409 with ``spool_id`` or
+  ``filament_id`` naming the holder, and with neither for a location
+- re-linking a UID to the spool that already holds it succeeds and changes nothing
+- ``GET /api/v1/spool?tag=<uid>`` answers the spool holding it, server side, and
+  like every spool listing leaves archived spools out unless ``allow_archived=true``
+- each spool carries its native tags under ``tags``
+
+An older server ignores the ``tag`` query parameter, as FastAPI does with any
+parameter a route does not declare, and answers the whole list.
+
+Failures a test needs can be switched on: ``refuse_400`` (UIDs the tag route
+rejects as invalid) and ``fail_patch`` (spool ids whose PATCH answers 500).
+
+Every request is logged as ``"METHOD /path"`` (``?tag=`` included), so a test can
+assert what was *not* asked: a scan that must not load the whole inventory, say.
+"""
+
+import json
+import re
+
+import httpx
+
+from backend.app.services.spoolman import SpoolmanClient
+
+BASE_URL = "http://spoolman.test:7912"
+
+_HEX = re.compile(r"^[0-9A-F]+$")
+
+
+def _normalise(uid: str) -> str:
+    return re.sub(r"[\s:\-]", "", uid).upper()
+
+
+class FakeSpoolman:
+    def __init__(self, *, tag_api: bool = True):
+        self.tag_api = tag_api
+        self.spools: dict[int, dict] = {}
+        # UIDs held by a filament or a location rather than a spool: the other 409s.
+        self.filament_tags: dict[str, int] = {}
+        self.location_tags: set[str] = set()
+        self.refuse_400: set[str] = set()
+        self.fail_patch: set[int] = set()
+        self.log: list[str] = []
+
+    def add_spool(self, spool_id: int, *, extra_tag: str | None = None, tags=(), archived: bool = False) -> dict:
+        spool = {
+            "id": spool_id,
+            "filament": {
+                "id": 1,
+                "name": "PLA Basic",
+                "material": "PLA",
+                "color_hex": "FF0000",
+                "weight": 1000.0,
+                "spool_weight": 196.0,
+                "vendor": {"id": 1, "name": "Bambu Lab"},
+            },
+            "remaining_weight": 800.0,
+            "used_weight": 200.0,
+            "archived": archived,
+            "registered": "2026-09-26T00:00:00Z",
+            "extra": {"tag": json.dumps(extra_tag)} if extra_tag is not None else {},
+            "tags": [],
+        }
+        if self.tag_api:
+            spool["tags"] = [{"uid": _normalise(u)} for u in tags]
+        else:
+            del spool["tags"]
+        self.spools[spool_id] = spool
+        return spool
+
+    def native(self, spool_id: int) -> list[str]:
+        return sorted(t["uid"] for t in self.spools[spool_id].get("tags", []))
+
+    def holder(self, uid: str) -> int | None:
+        return next((s["id"] for s in self.spools.values() if uid in self.native(s["id"])), None)
+
+    def asked(self, entry: str) -> int:
+        return self.log.count(entry)
+
+    def tag_writes(self) -> list[str]:
+        return [e for e in self.log if "/tag" in e and not e.startswith("GET /tag/reader")]
+
+    async def handler(self, request: httpx.Request) -> httpx.Response:
+        path = request.url.path.removeprefix("/api/v1")
+        tag_param = request.url.params.get("tag")
+        self.log.append(f"{request.method} {path}" + (f"?tag={tag_param}" if tag_param else ""))
+        body = json.loads(request.content) if request.content else {}
+
+        if path == "/health":
+            return httpx.Response(200, json={"status": "healthy"})
+
+        tag_route = path == "/tag/reader" or re.fullmatch(r"/spool/\d+/tag(/.*)?", path)
+        if tag_route and not self.tag_api:
+            return httpx.Response(404, json={"detail": "Not Found"})
+
+        if path == "/tag/reader":
+            return httpx.Response(200, json=[])
+
+        if path == "/spool" and request.method == "GET":
+            if tag_param is not None and self.tag_api:
+                uid = _normalise(tag_param)
+                if not _HEX.match(uid):
+                    return httpx.Response(400, json={"message": f"Invalid tag UID {tag_param!r}."})
+            allow_archived = request.url.params.get("allow_archived") == "true"
+            if tag_param is not None and self.tag_api:
+                holder = self.holder(uid)
+                found = [self.spools[holder]] if holder is not None else []
+                return httpx.Response(200, json=[x for x in found if allow_archived or not x.get("archived")])
+            return httpx.Response(
+                200, json=[s for s in self.spools.values() if allow_archived or not s.get("archived")]
+            )
+
+        if m := re.fullmatch(r"/spool/(\d+)/tag", path):
+            spool_id = int(m.group(1))
+            if spool_id not in self.spools:
+                return httpx.Response(404, json={"message": f"No spool with ID {spool_id} found."})
+            uid = _normalise(body.get("uid", ""))
+            if not uid or not _HEX.match(uid) or uid in self.refuse_400:
+                return httpx.Response(400, json={"message": f"Invalid tag UID {body.get('uid')!r}."})
+            if uid in self.location_tags:
+                return httpx.Response(409, json={"message": f"Tag {uid} is already linked to location Shelf."})
+            if uid in self.filament_tags:
+                return httpx.Response(
+                    409, json={"message": "Tag is linked to a filament.", "filament_id": self.filament_tags[uid]}
+                )
+            holder = self.holder(uid)
+            if holder is not None and holder != spool_id:
+                return httpx.Response(
+                    409, json={"message": f"Tag {uid} is already linked to spool {holder}.", "spool_id": holder}
+                )
+            if holder is None:
+                tag = {"uid": uid}
+                if body.get("format"):
+                    tag["format"] = body["format"]
+                self.spools[spool_id]["tags"].append(tag)
+            return httpx.Response(201, json={"uid": uid})
+
+        if m := re.fullmatch(r"/spool/(\d+)/tag/([^/]+)", path):
+            spool_id, uid = int(m.group(1)), _normalise(m.group(2))
+            if spool_id not in self.spools or uid not in self.native(spool_id):
+                return httpx.Response(404, json={"message": "Tag not linked to this spool."})
+            self.spools[spool_id]["tags"] = [t for t in self.spools[spool_id]["tags"] if t["uid"] != uid]
+            return httpx.Response(204)
+
+        if m := re.fullmatch(r"/spool/(\d+)", path):
+            spool_id = int(m.group(1))
+            if spool_id not in self.spools:
+                return httpx.Response(404, json={"message": f"No spool with ID {spool_id} found."})
+            spool = self.spools[spool_id]
+            if request.method == "PATCH" and spool_id in self.fail_patch:
+                return httpx.Response(500, json={"message": "Internal Server Error"})
+            if request.method == "PATCH":
+                # Spoolman merges extra key by key, like the real one.
+                extra = body.pop("extra", None)
+                spool.update(body)
+                if extra is not None:
+                    spool["extra"] = {**spool.get("extra", {}), **extra}
+            return httpx.Response(200, json=spool)
+
+        if path == "/field/spool" and request.method == "GET":
+            return httpx.Response(200, json=[{"key": "tag", "name": "tag", "field_type": "text"}])
+        if path.startswith("/field/spool/") and request.method == "POST":
+            return httpx.Response(200, json={"key": path.rsplit("/", 1)[-1]})
+
+        return httpx.Response(404, json={"detail": f"Not modelled: {request.method} {path}"})
+
+
+def client_for(fake: FakeSpoolman) -> SpoolmanClient:
+    client = SpoolmanClient(BASE_URL)
+    client._client = httpx.AsyncClient(transport=httpx.MockTransport(fake.handler))
+    return client

+ 3 - 0
backend/tests/integration/test_ams_slot_material_2902.py

@@ -63,6 +63,9 @@ def _spoolman_spool(material, spool_id=11, slicer_filament=None):
 
 def _spoolman_client(spool):
     client = MagicMock()
+    client.has_tag_api = AsyncMock(return_value=False)
+    client.add_native_tags = AsyncMock(return_value=0)
+    client.unlink_all_native_tags = AsyncMock()
     client.base_url = "http://localhost:7912"
     client.health_check = AsyncMock(return_value=True)
     client.get_spool = AsyncMock(return_value=spool)

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

@@ -456,6 +456,9 @@ class TestNfcEndpoints:
             "used_weight": 0.0,
         }
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.get_spools = AsyncMock(return_value=[sm_match])
         mock_client.find_spool_by_tag = AsyncMock(return_value=sm_match)
 
@@ -520,6 +523,9 @@ class TestNfcEndpoints:
         }
 
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.get_spools = AsyncMock(return_value=[spool_a_with_tag])
         mock_client.find_spool_by_tag = AsyncMock(return_value=spool_a_with_tag)
         mock_client.merge_spool_extra = AsyncMock(return_value={})
@@ -713,6 +719,9 @@ class TestNfcEndpoints:
             "used_weight": 0.0,
         }
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.get_spools = AsyncMock(return_value=[sm_spool])
         mock_client.find_spool_by_tag = AsyncMock(
             side_effect=lambda tag, cached_spools=None: sm_spool if tag == "AABB1122" else None
@@ -739,6 +748,9 @@ class TestNfcEndpoints:
             "used_weight": 0.0,
         }
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.get_spools = AsyncMock(return_value=[sm_spool])
         mock_client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
         mock_client.merge_spool_extra = AsyncMock(return_value={})
@@ -763,6 +775,9 @@ class TestNfcEndpoints:
             "used_weight": 0.0,
         }
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.get_spools = AsyncMock(return_value=[sm_spool])
         mock_client.find_spool_by_tag = AsyncMock(
             side_effect=lambda tag, cached_spools=None: sm_spool if tag == "AABB1122" else None
@@ -1681,6 +1696,9 @@ async def spoolman_settings(db_session: AsyncSession):
 
 def _mock_spoolman_client(base_url: str = "http://spoolman.local:7912") -> MagicMock:
     client = MagicMock()
+    client.has_tag_api = AsyncMock(return_value=False)
+    client.add_native_tags = AsyncMock(return_value=0)
+    client.unlink_all_native_tags = AsyncMock()
     client.base_url = base_url
     client.get_spools = AsyncMock(return_value=[])
     client.get_spool = AsyncMock(return_value={})

+ 3 - 0
backend/tests/integration/test_spoolbuddy_color_name_3090.py

@@ -58,6 +58,9 @@ def _spoolman_spool_without_a_colour_name() -> dict:
 
 def _mock_spoolman_client(spool: dict) -> MagicMock:
     client = MagicMock()
+    client.has_tag_api = AsyncMock(return_value=False)
+    client.add_native_tags = AsyncMock(return_value=0)
+    client.unlink_all_native_tags = AsyncMock()
     client.base_url = "http://spoolman.local:7912"
     client.get_spools = AsyncMock(return_value=[spool])
     client.find_spool_by_tag = AsyncMock(return_value=spool)

+ 3 - 0
backend/tests/integration/test_spoolbuddy_spool_colours_3033.py

@@ -86,6 +86,9 @@ class TestTheScanBroadcastCarriesTheSpoolsColours:
             "registered": "2024-01-01T00:00:00Z",
         }
         client = MagicMock()
+        client.has_tag_api = AsyncMock(return_value=False)
+        client.add_native_tags = AsyncMock(return_value=0)
+        client.unlink_all_native_tags = AsyncMock()
         client.base_url = "http://spoolman.local:7912"
         client.get_spools = AsyncMock(return_value=[sm_spool])
         client.find_spool_by_tag = AsyncMock(return_value=sm_spool)

+ 9 - 0
backend/tests/integration/test_spoolbuddy_spoolman_nfc.py

@@ -70,6 +70,9 @@ def _spoolman_spool(spool_id: int) -> dict:
 
 def _mock_spoolman_client_local() -> MagicMock:
     client = MagicMock()
+    client.has_tag_api = AsyncMock(return_value=False)
+    client.add_native_tags = AsyncMock(return_value=0)
+    client.unlink_all_native_tags = AsyncMock()
     client.base_url = "http://spoolman.local:7912"
     client.get_spools = AsyncMock(return_value=[])
     client.find_spool_by_tag = AsyncMock(return_value=None)
@@ -265,6 +268,9 @@ class TestLinkTagToSpoolmanSpool:
 
     def _mock_client(self, spool_id: int) -> MagicMock:
         client = MagicMock()
+        client.has_tag_api = AsyncMock(return_value=False)
+        client.add_native_tags = AsyncMock(return_value=0)
+        client.unlink_all_native_tags = AsyncMock()
         client.base_url = "http://localhost:7912"
         # get_all_spools returns empty list — no duplicate tags in Spoolman.
         client.get_all_spools = AsyncMock(return_value=[])
@@ -339,6 +345,9 @@ class TestLinkTagToSpoolmanSpool:
     async def test_spool_not_found_returns_404(self, async_client: AsyncClient):
         """404 when Spoolman reports the spool does not exist."""
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.get_all_spools = AsyncMock(return_value=[])
         mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 999 not found"))
 

+ 6 - 0
backend/tests/integration/test_spoolman_api.py

@@ -36,6 +36,9 @@ class TestSpoolmanAPI:
     def mock_spoolman_client(self):
         """Mock the Spoolman client functions."""
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.is_connected = True
         mock_client.base_url = "http://localhost:7912"
         mock_client.health_check = AsyncMock(return_value=True)
@@ -1162,6 +1165,9 @@ class TestLinkSpoolMqttConfigure:
     @pytest.fixture
     def mock_spoolman_client(self):
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.is_connected = True
         mock_client.base_url = "http://localhost:7912"
         mock_client.health_check = AsyncMock(return_value=True)

+ 15 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -55,6 +55,9 @@ async def spoolman_settings(db_session):
 def mock_spoolman_client():
     """Mock the Spoolman client with a sample spool."""
     mock_client = MagicMock()
+    mock_client.has_tag_api = AsyncMock(return_value=False)
+    mock_client.add_native_tags = AsyncMock(return_value=0)
+    mock_client.unlink_all_native_tags = AsyncMock()
     mock_client.base_url = "http://localhost:7912"
     mock_client.health_check = AsyncMock(return_value=True)
     mock_client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL])
@@ -2277,6 +2280,9 @@ class TestUnlinkSpool:
             "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"', "custom": "keep"},
         }
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.base_url = "http://localhost:7912"
         mock_client.health_check = AsyncMock(return_value=True)
         mock_client.get_spool = AsyncMock(return_value=spool_with_tag)
@@ -2416,6 +2422,9 @@ class TestCreateSpoolWithFilamentId:
     async def test_create_with_filament_id_skips_find_or_create(self, async_client: AsyncClient, spoolman_settings):
         """When spoolman_filament_id is provided, find_or_create_filament must NOT be called."""
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.find_or_create_filament = AsyncMock(return_value=7)
         mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
         mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
@@ -2441,6 +2450,9 @@ class TestCreateSpoolWithFilamentId:
         from backend.app.services.spoolman import SpoolmanNotFoundError
 
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.create_spool = AsyncMock(side_effect=SpoolmanNotFoundError("filament not found"))
         with patch(
             "backend.app.api.routes.spoolman_inventory._get_client",
@@ -2470,6 +2482,9 @@ class TestBulkCreateWithFilamentId:
     ):
         """Bulk POST with spoolman_filament_id must NOT call find_or_create_filament."""
         mock_client = MagicMock()
+        mock_client.has_tag_api = AsyncMock(return_value=False)
+        mock_client.add_native_tags = AsyncMock(return_value=0)
+        mock_client.unlink_all_native_tags = AsyncMock()
         mock_client.find_or_create_filament = AsyncMock(return_value=7)
         mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
         mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)

+ 522 - 0
backend/tests/integration/test_spoolman_native_tags_3168.py

@@ -0,0 +1,522 @@
+"""The Spoolman routes against Spoolman 0.27's native tags, and against an older server (#3168).
+
+The routes run with a real SpoolmanClient over HTTP to a fake that enforces the
+server's tag rules (``backend/tests/_fixtures/spoolman_tags.py``). Only the
+client lookup and the websocket broadcast are patched.
+"""
+
+import json
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
+from backend.tests._fixtures.spoolman_tags import BASE_URL, FakeSpoolman, client_for
+
+SCAN = "/api/v1/spoolbuddy/nfc/tag-scanned"
+INVENTORY = "/api/v1/spoolman/inventory"
+
+TRAY_UUID = "9E0B0717BEE94D7887EB1D8DFD1A14F3"
+CHIP = "D3E68F32"
+OTHER_CHIP = "A1B2C3D4"
+
+
+@pytest.fixture
+async def spoolman_on(db_session: AsyncSession):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value=BASE_URL))
+    await db_session.commit()
+
+
+@contextmanager
+def serving(fake: FakeSpoolman):
+    """Every route that asks for the Spoolman client gets one talking to ``fake``."""
+    client = client_for(fake)
+    getter = AsyncMock(return_value=client)
+    with (
+        patch("backend.app.services.spoolman.get_spoolman_client", getter),
+        patch("backend.app.services.spoolman.init_spoolman_client", getter),
+        patch("backend.app.api.routes.spoolman_inventory.get_spoolman_client", getter),
+        patch("backend.app.api.routes.spoolman_inventory.init_spoolman_client", getter),
+        patch("backend.app.api.routes.spoolman.get_spoolman_client", getter),
+        patch("backend.app.api.routes.spoolman.init_spoolman_client", getter),
+        patch("backend.app.api.routes.spoolbuddy.ws_manager") as ws,
+        patch("backend.app.api.routes.spoolman_inventory.ws_manager") as inventory_ws,
+    ):
+        ws.broadcast = AsyncMock()
+        inventory_ws.broadcast = AsyncMock()
+        yield ws
+
+
+async def _scan(async_client: AsyncClient, tag_uid: str, tray_uuid: str | None = None) -> dict:
+    payload = {"device_id": "sb-1", "tag_uid": tag_uid}
+    if tray_uuid:
+        payload["tray_uuid"] = tray_uuid
+    resp = await async_client.post(SCAN, json=payload)
+    assert resp.status_code == 200
+    return resp.json()
+
+
+class TestSpoolBuddyScan:
+    async def test_a_native_tag_matches_without_loading_the_inventory(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        for spool_id in range(1, 30):
+            fake.add_spool(spool_id)
+        fake.add_spool(30, extra_tag=TRAY_UUID, tags=[TRAY_UUID, CHIP])
+
+        with serving(fake):
+            result = await _scan(async_client, CHIP, TRAY_UUID)
+
+        assert result["spool_id"] == 30
+        assert fake.asked("GET /spool") == 0
+
+    async def test_a_spool_found_through_extra_tag_moves_over_on_its_first_scan(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7, extra_tag=TRAY_UUID)
+
+        with serving(fake):
+            first = await _scan(async_client, CHIP, TRAY_UUID)
+            listings_after_first = fake.asked("GET /spool")
+            second = await _scan(async_client, OTHER_CHIP, TRAY_UUID)
+
+        assert first["spool_id"] == second["spool_id"] == 7
+        assert listings_after_first == 1
+        assert fake.asked("GET /spool") == 1, "the second scan loaded the inventory again"
+        # Both sides of the spool are linked now, each by the chip its reader saw.
+        assert fake.native(7) == sorted([TRAY_UUID, CHIP, OTHER_CHIP])
+
+    async def test_the_tray_uuid_in_extra_tag_still_wins_over_a_native_chip_uid(self, async_client, spoolman_on):
+        """The order the scan always had: tray UUID first, even before it has moved over.
+
+        Spool 9 carries the UUID only in extra.tag, spool 7 the chip natively. The
+        scan belongs to spool 9, and spool 7 must not be given the UUID.
+        """
+        fake = FakeSpoolman()
+        fake.add_spool(7, extra_tag=CHIP, tags=[CHIP])
+        fake.add_spool(9, extra_tag=TRAY_UUID)
+
+        with serving(fake):
+            result = await _scan(async_client, CHIP, TRAY_UUID)
+
+        assert result["spool_id"] == 9
+        assert fake.spools[7]["extra"]["tag"] == json.dumps(CHIP)
+        assert fake.holder(TRAY_UUID) == 9
+        assert fake.holder(CHIP) == 7
+
+    async def test_a_tag_left_on_an_archived_spool_moves_to_its_successor(self, async_client, spoolman_on):
+        """The archived spool holds the chip natively; its active successor has it in extra.tag."""
+        fake = FakeSpoolman()
+        fake.add_spool(1, extra_tag=CHIP, tags=[CHIP], archived=True)
+        fake.add_spool(9, extra_tag=CHIP)
+
+        with serving(fake):
+            first = await _scan(async_client, CHIP)
+            second = await _scan(async_client, CHIP)
+
+        assert first["spool_id"] == second["spool_id"] == 9
+        assert fake.holder(CHIP) == 9
+        assert fake.asked("POST /spool/9/tag") == 2  # refused once, then linked after the move
+
+    async def test_a_scan_right_after_settling_a_conflict_adds_the_tag(self, async_client, spoolman_on):
+        """A refusal holds off the AMS sync, not someone scanning at the reader.
+
+        Spool 7 is found by its tray UUID; its chip is linked to spool 1 by mistake.
+        The conflict is settled in Spoolman and the spool scanned again at once.
+        """
+        fake = FakeSpoolman()
+        fake.add_spool(1, tags=[CHIP])
+        fake.add_spool(7, extra_tag=TRAY_UUID, tags=[TRAY_UUID])
+
+        with serving(fake):
+            first = await _scan(async_client, CHIP, TRAY_UUID)
+            assert (first["spool_id"], fake.holder(CHIP)) == (7, 1)
+            fake.spools[1]["tags"] = []
+            second = await _scan(async_client, CHIP, TRAY_UUID)
+
+        assert second["spool_id"] == 7
+        assert fake.holder(CHIP) == 7
+
+    async def test_an_older_server_matches_through_extra_tag_and_writes_no_tags(self, async_client, spoolman_on):
+        fake = FakeSpoolman(tag_api=False)
+        fake.add_spool(7, extra_tag=TRAY_UUID)
+
+        with serving(fake):
+            result = await _scan(async_client, CHIP, TRAY_UUID)
+
+        assert result["spool_id"] == 7
+        assert fake.tag_writes() == []
+
+
+class TestLinkTag:
+    async def test_the_tags_are_added_to_what_the_spool_carries(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7, tags=[OTHER_CHIP])
+
+        with serving(fake):
+            resp = await async_client.patch(
+                f"{INVENTORY}/spools/7/tag", json={"tray_uuid": TRAY_UUID, "tag_uid": CHIP.lower()}
+            )
+
+        assert resp.status_code == 200
+        assert fake.native(7) == sorted([TRAY_UUID, CHIP, OTHER_CHIP])
+        assert fake.spools[7]["extra"]["tag"] == json.dumps(TRAY_UUID)
+
+    async def test_a_tag_another_spool_holds_natively_is_refused_and_nothing_stays_behind(
+        self, async_client, spoolman_on
+    ):
+        """Spool 9 holds the chip only as a native tag, so the extra.tag check cannot see it."""
+        fake = FakeSpoolman()
+        fake.add_spool(7, tags=[OTHER_CHIP])
+        fake.add_spool(9, tags=[CHIP])
+
+        with serving(fake):
+            resp = await async_client.patch(f"{INVENTORY}/spools/7/tag", json={"tray_uuid": TRAY_UUID, "tag_uid": CHIP})
+
+        assert resp.status_code == 409
+        detail = resp.json()["detail"]
+        assert (detail["code"], detail["spool_id"], detail["field"]) == ("tag_already_linked", 9, "tag_uid")
+        # The tray UUID this request linked first is taken back; the tag spool 7 had stays.
+        assert fake.native(7) == [OTHER_CHIP]
+        assert fake.spools[7]["extra"] == {}
+
+    @pytest.mark.parametrize("holder", ["filament", "location"])
+    async def test_a_tag_a_filament_or_location_holds_is_refused_without_naming_a_spool(
+        self, async_client, spoolman_on, holder
+    ):
+        fake = FakeSpoolman()
+        fake.add_spool(7)
+        if holder == "filament":
+            fake.filament_tags[CHIP] = 4
+        else:
+            fake.location_tags.add(CHIP)
+
+        with serving(fake):
+            resp = await async_client.patch(f"{INVENTORY}/spools/7/tag", json={"tray_uuid": TRAY_UUID, "tag_uid": CHIP})
+
+        assert resp.status_code == 409
+        detail = resp.json()["detail"]
+        assert (detail["code"], detail["field"]) == ("tag_linked_elsewhere", "tag_uid")
+        assert "spool_id" not in detail
+        assert fake.native(7) == []
+
+    async def test_a_tag_an_archived_spool_holds_moves_to_the_spool(self, async_client, spoolman_on):
+        """The archived spool is the one this spool replaced; Bambuddy cannot even show it."""
+        fake = FakeSpoolman()
+        fake.add_spool(1, tags=[CHIP], archived=True)
+        fake.add_spool(7)
+
+        with serving(fake):
+            resp = await async_client.patch(f"{INVENTORY}/spools/7/tag", json={"tag_uid": CHIP})
+
+        assert resp.status_code == 200
+        assert fake.native(7) == [CHIP]
+        assert fake.native(1) == []
+
+    async def test_a_failed_extra_tag_write_takes_back_what_the_request_added(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7, tags=[OTHER_CHIP])
+        fake.fail_patch.add(7)
+
+        with serving(fake):
+            resp = await async_client.patch(f"{INVENTORY}/spools/7/tag", json={"tray_uuid": TRAY_UUID, "tag_uid": CHIP})
+
+        assert resp.status_code >= 500
+        assert fake.native(7) == [OTHER_CHIP]
+
+
+class TestLinkSpoolRoute:
+    """POST /spoolman/spools/{id}/link, the AMS slot's "Link to Spoolman"."""
+
+    async def test_a_refused_native_tag_leaves_extra_tag_untouched(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7)
+        fake.refuse_400.add(TRAY_UUID)
+
+        with serving(fake):
+            resp = await async_client.post("/api/v1/spoolman/spools/7/link", json={"spool_tag": TRAY_UUID})
+
+        assert resp.status_code == 502
+        assert fake.spools[7]["extra"] == {}
+        assert fake.native(7) == []
+
+    async def test_a_failed_extra_tag_write_takes_the_native_tag_back(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7, tags=[OTHER_CHIP])
+        fake.fail_patch.add(7)
+
+        with serving(fake):
+            resp = await async_client.post("/api/v1/spoolman/spools/7/link", json={"spool_tag": TRAY_UUID})
+
+        assert resp.status_code >= 500
+        assert fake.native(7) == [OTHER_CHIP]
+
+    async def test_a_tag_the_spool_already_had_stays_when_the_write_fails(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7, tags=[TRAY_UUID])
+        fake.fail_patch.add(7)
+
+        with serving(fake):
+            resp = await async_client.post("/api/v1/spoolman/spools/7/link", json={"spool_tag": TRAY_UUID})
+
+        assert resp.status_code >= 500
+        assert fake.native(7) == [TRAY_UUID]
+
+
+class TestNfcWriteResult:
+    """SpoolBuddy reports a tag it has just written for a Spoolman spool."""
+
+    @pytest.fixture
+    async def device(self, db_session: AsyncSession):
+        db_session.add(
+            SpoolBuddyDevice(
+                device_id="sb-write",
+                hostname="spoolbuddy",
+                ip_address="10.0.0.9",
+                firmware_version="1.0.0",
+                has_nfc=True,
+                has_scale=True,
+                tare_offset=0,
+                calibration_factor=1.0,
+                last_seen=datetime.now(timezone.utc),
+                pending_command="write_tag",
+                pending_write_payload=json.dumps(
+                    {"spool_id": 7, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}
+                ),
+            )
+        )
+        await db_session.commit()
+
+    async def _report(self, async_client):
+        return await async_client.post(
+            "/api/v1/spoolbuddy/nfc/write-result",
+            json={"device_id": "sb-write", "spool_id": 7, "tag_uid": CHIP, "success": True},
+        )
+
+    async def test_the_written_tag_moves_from_its_previous_spool(self, async_client, spoolman_on, device):
+        fake = FakeSpoolman()
+        fake.add_spool(1, tags=[CHIP])
+        fake.add_spool(7)
+
+        with serving(fake):
+            resp = await self._report(async_client)
+
+        assert resp.status_code == 200
+        assert (fake.native(1), fake.native(7)) == ([], [CHIP])
+        assert fake.spools[7]["extra"]["tag"] == json.dumps(CHIP)
+
+    async def test_a_tag_the_spool_already_holds_is_left_as_it_is(self, async_client, spoolman_on, device):
+        fake = FakeSpoolman()
+        fake.add_spool(7, tags=[CHIP])
+
+        with serving(fake):
+            resp = await self._report(async_client)
+
+        assert resp.status_code == 200
+        assert fake.tag_writes() == []
+        assert fake.native(7) == [CHIP]
+        assert fake.spools[7]["extra"]["tag"] == json.dumps(CHIP)
+
+    async def test_a_refused_native_tag_leaves_extra_tag_untouched(self, async_client, spoolman_on, device):
+        fake = FakeSpoolman()
+        fake.add_spool(7)
+        fake.refuse_400.add(CHIP)
+
+        with serving(fake):
+            resp = await self._report(async_client)
+
+        assert resp.status_code == 502
+        assert fake.spools[7]["extra"] == {}
+
+    async def test_a_failed_extra_tag_write_takes_the_native_tag_back(self, async_client, spoolman_on, device):
+        fake = FakeSpoolman()
+        fake.add_spool(7)
+        fake.fail_patch.add(7)
+
+        with serving(fake):
+            resp = await self._report(async_client)
+
+        assert resp.status_code == 502
+        assert fake.native(7) == []
+
+
+class TestClearRfidTag:
+    async def test_clearing_removes_extra_tag_and_the_native_tags(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7, extra_tag=TRAY_UUID, tags=[TRAY_UUID, CHIP])
+
+        with serving(fake):
+            resp = await async_client.patch(f"{INVENTORY}/spools/7", json={"tag_uid": None})
+
+        assert resp.status_code == 200
+        assert fake.spools[7]["extra"]["tag"] == json.dumps("")
+        assert fake.native(7) == []
+        assert resp.json()["tray_uuid"] is None
+
+    async def test_a_failed_update_keeps_the_native_tags(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7, extra_tag=TRAY_UUID, tags=[TRAY_UUID, CHIP])
+        fake.fail_patch.add(7)
+
+        with serving(fake):
+            resp = await async_client.patch(f"{INVENTORY}/spools/7", json={"tag_uid": None})
+
+        assert resp.status_code >= 500
+        assert fake.native(7) == sorted([TRAY_UUID, CHIP])
+
+
+class TestUnlink:
+    async def test_unlinking_a_spool_removes_its_native_tags(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(7, extra_tag=TRAY_UUID, tags=[TRAY_UUID, CHIP])
+
+        with serving(fake):
+            resp = await async_client.post("/api/v1/spoolman/spools/7/unlink")
+
+        assert resp.status_code == 200
+        assert fake.native(7) == []
+        assert fake.spools[7]["extra"]["tag"] == json.dumps("")
+
+    async def test_an_older_server_is_not_asked_for_the_spool_again(self, async_client, spoolman_on):
+        fake = FakeSpoolman(tag_api=False)
+        fake.add_spool(7, extra_tag=TRAY_UUID)
+
+        with serving(fake):
+            resp = await async_client.post("/api/v1/spoolman/spools/7/unlink")
+
+        assert resp.status_code == 200
+        reads_of_spool_7 = fake.asked("GET /spool/7")
+        # merge_spool_extra reads the spool once to merge into it; nothing more.
+        assert reads_of_spool_7 == 1
+
+
+class TestMigration:
+    @pytest.fixture
+    async def printer(self, db_session: AsyncSession) -> Printer:
+        printer = Printer(
+            name="X1C",
+            serial_number="00M09A350100123",
+            ip_address="192.168.0.50",
+            access_code="12345678",
+            model="X1C",
+            is_active=True,
+        )
+        db_session.add(printer)
+        await db_session.commit()
+        return printer
+
+    def _inventory(self, printer: Printer) -> FakeSpoolman:
+        fake = FakeSpoolman()
+        fake.add_spool(1, extra_tag=TRAY_UUID)  # to move
+        fake.add_spool(2, extra_tag=CHIP, tags=[CHIP])  # already native
+        fake.add_spool(3, extra_tag=get_fallback_spool_tag_for_slot(printer.serial_number, 0, 1))  # a slot, not a tag
+        fake.add_spool(4, extra_tag=OTHER_CHIP)  # spool 5 holds it natively
+        fake.add_spool(5, tags=[OTHER_CHIP])
+        fake.add_spool(6, extra_tag="11223344", archived=True)  # archived: left out
+        fake.add_spool(8)  # no tag at all
+        return fake
+
+    async def test_the_dry_run_reports_the_real_runs_outcome_and_writes_nothing(
+        self, async_client, spoolman_on, printer
+    ):
+        fake = self._inventory(printer)
+
+        with serving(fake):
+            resp = await async_client.post(f"{INVENTORY}/tags/migrate")
+
+        assert resp.status_code == 200
+        report = resp.json()
+        assert report["dry_run"] is True
+        assert report["moved"] == [1]
+        assert (report["already"], report["slot_ids"]) == (1, 1)
+        assert report["conflicts"] == [{"spool_id": 4, "tag": OTHER_CHIP, "holder": 5}]
+        assert fake.tag_writes() == []
+
+    async def test_the_real_run_moves_what_the_dry_run_named(self, async_client, spoolman_on, printer):
+        fake = self._inventory(printer)
+
+        with serving(fake):
+            dry = (await async_client.post(f"{INVENTORY}/tags/migrate")).json()
+            real = (await async_client.post(f"{INVENTORY}/tags/migrate", params={"dry_run": "false"})).json()
+
+        assert real["moved"] == dry["moved"] == [1]
+        assert real["conflicts"] == dry["conflicts"]
+        assert fake.native(1) == [TRAY_UUID]
+        assert fake.native(6) == []
+        assert fake.native(3) == []
+        assert fake.native(4) == []
+        # extra.tag is left as it was, for older readers and a downgrade.
+        assert fake.spools[1]["extra"]["tag"] == json.dumps(TRAY_UUID)
+
+    async def test_two_spools_sharing_one_extra_tag_move_the_first_and_report_the_second(
+        self, async_client, spoolman_on
+    ):
+        fake = FakeSpoolman()
+        fake.add_spool(1, extra_tag=CHIP)
+        fake.add_spool(2, extra_tag=CHIP)
+
+        with serving(fake):
+            dry = (await async_client.post(f"{INVENTORY}/tags/migrate")).json()
+            real = (await async_client.post(f"{INVENTORY}/tags/migrate", params={"dry_run": "false"})).json()
+
+        expected = [{"spool_id": 2, "tag": CHIP, "holder": 1}]
+        assert dry["conflicts"] == real["conflicts"] == expected
+        assert fake.holder(CHIP) == 1
+
+    async def test_an_archived_spool_and_its_successor_sharing_a_tag(self, async_client, spoolman_on):
+        """The case from the review: the tag goes to the active spool, not the archived one."""
+        fake = FakeSpoolman()
+        fake.add_spool(1, extra_tag=CHIP, archived=True)
+        fake.add_spool(9, extra_tag=CHIP)
+
+        with serving(fake):
+            dry = (await async_client.post(f"{INVENTORY}/tags/migrate")).json()
+            real = (await async_client.post(f"{INVENTORY}/tags/migrate", params={"dry_run": "false"})).json()
+
+        assert dry["moved"] == real["moved"] == [9]
+        assert dry["conflicts"] == real["conflicts"] == []
+        assert fake.holder(CHIP) == 9
+
+    async def test_a_tag_already_on_an_archived_spool_is_moved_not_reported(self, async_client, spoolman_on):
+        fake = FakeSpoolman()
+        fake.add_spool(1, extra_tag=CHIP, tags=[CHIP], archived=True)
+        fake.add_spool(9, extra_tag=CHIP)
+
+        with serving(fake):
+            dry = (await async_client.post(f"{INVENTORY}/tags/migrate")).json()
+            real = (await async_client.post(f"{INVENTORY}/tags/migrate", params={"dry_run": "false"})).json()
+
+        assert dry["moved"] == real["moved"] == [9]
+        assert dry["conflicts"] == real["conflicts"] == []
+        assert (fake.native(1), fake.native(9)) == ([], [CHIP])
+
+    async def test_an_older_server_is_refused(self, async_client, spoolman_on):
+        fake = FakeSpoolman(tag_api=False)
+        fake.add_spool(1, extra_tag=TRAY_UUID)
+
+        with serving(fake):
+            resp = await async_client.post(f"{INVENTORY}/tags/migrate", params={"dry_run": "false"})
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["code"] == "spoolman_without_tags"
+        assert fake.tag_writes() == []
+
+
+class TestStatus:
+    async def test_a_027_server_is_reported_with_native_tags(self, async_client, spoolman_on):
+        with serving(FakeSpoolman()):
+            status = (await async_client.get("/api/v1/spoolman/status")).json()
+
+        assert (status["connected"], status["native_tags"]) == (True, True)
+
+    async def test_an_older_server_is_reported_without(self, async_client, spoolman_on):
+        with serving(FakeSpoolman(tag_api=False)):
+            status = (await async_client.get("/api/v1/spoolman/status")).json()
+
+        assert (status["connected"], status["native_tags"]) == (True, False)

+ 2 - 0
backend/tests/integration/test_spoolman_status_2903.py

@@ -41,6 +41,7 @@ def _client(*, healthy: bool = True, base_url: str = "http://localhost:7912") ->
     client = MagicMock()
     client.base_url = base_url
     client.health_check = AsyncMock(return_value=healthy)
+    client.has_tag_api = AsyncMock(return_value=False)
     return client
 
 
@@ -110,6 +111,7 @@ class TestItAsksSpoolmanRatherThanItself:
             "enabled": True,
             "connected": False,
             "url": "http://localhost:7912",
+            "native_tags": False,
         }
 
 

+ 4 - 0
backend/tests/unit/services/test_spoolman_extra_field_registration_2903.py

@@ -67,6 +67,10 @@ class FakeSpoolman:
         self.log.append(f"{request.method} {path}")
         body = json.loads(request.content) if request.content else {}
 
+        # A Spoolman before 0.27, which has no tag endpoints.
+        if path.startswith("/tag/"):
+            return httpx.Response(404, json={"detail": "Not Found"})
+
         if path == "/field/spool" and request.method == "GET":
             if self.field_status != 200:
                 return httpx.Response(self.field_status)

+ 296 - 0
backend/tests/unit/services/test_spoolman_native_tags_3168.py

@@ -0,0 +1,296 @@
+"""SpoolmanClient against Spoolman 0.27's native tags, and against an older server (#3168).
+
+Every test runs the real client over HTTP against a fake that enforces the
+server's tag rules (see ``backend/tests/_fixtures/spoolman_tags.py``), so what
+is asserted is what goes over the wire, not which mock method was called.
+"""
+
+from unittest.mock import patch
+
+import httpx
+import pytest
+
+from backend.app.services.spoolman import (
+    TAG_API_RECHECK_SECONDS,
+    TAG_REFUSAL_RETRY_SECONDS,
+    AMSTray,
+    SpoolmanClient,
+)
+from backend.tests._fixtures.spoolman_tags import BASE_URL, FakeSpoolman, client_for
+
+TRAY_UUID = "9E0B0717BEE94D7887EB1D8DFD1A14F3"
+CHIP = "D3E68F32"
+# How the AMS reports that chip: its 4 bytes, padded to 8.
+CHIP_FROM_AMS = "D3E68F3200000100"
+
+
+class _NoCatalog:
+    """A DB session whose colour catalogue has no row for anything."""
+
+    async def execute(self, *_args, **_kwargs):
+        class _Result:
+            @staticmethod
+            def scalar_one_or_none():
+                return None
+
+        return _Result()
+
+
+def _tray(tray_uuid: str = TRAY_UUID, tag_uid: str = CHIP_FROM_AMS) -> AMSTray:
+    return AMSTray(
+        ams_id=0,
+        tray_id=0,
+        tray_type="PLA",
+        tray_sub_brands="PLA Basic",
+        tray_color="FF0000FF",
+        remain=80,
+        tag_uid=tag_uid,
+        tray_uuid=tray_uuid,
+        tray_info_idx="GFA00",
+        tray_weight=1000,
+    )
+
+
+class TestHasTagApi:
+    async def test_a_027_server_is_asked_once(self):
+        fake = FakeSpoolman()
+        client = client_for(fake)
+
+        assert await client.has_tag_api() is True
+        assert await client.has_tag_api() is True
+        assert fake.asked("GET /tag/reader") == 1
+
+    async def test_an_older_server_answers_404_and_is_not_asked_again(self):
+        fake = FakeSpoolman(tag_api=False)
+        client = client_for(fake)
+
+        assert await client.has_tag_api() is False
+        assert await client.has_tag_api() is False
+        assert fake.asked("GET /tag/reader") == 1
+
+    async def test_a_server_that_is_down_is_asked_again_next_time(self):
+        calls = []
+
+        async def down(request: httpx.Request) -> httpx.Response:
+            calls.append(request.url.path)
+            raise httpx.ConnectError("refused", request=request)
+
+        client = SpoolmanClient(BASE_URL)
+        client._client = httpx.AsyncClient(transport=httpx.MockTransport(down))
+
+        assert await client.has_tag_api() is False
+        assert await client.has_tag_api() is False
+        assert len(calls) == 2
+
+    async def test_a_no_is_asked_again_later_so_an_upgrade_is_picked_up(self):
+        fake = FakeSpoolman(tag_api=False)
+        client = client_for(fake)
+        now = [1000.0]
+
+        with patch("backend.app.services.spoolman.time.monotonic", lambda: now[0]):
+            assert await client.has_tag_api() is False
+            fake.tag_api = True  # Spoolman upgraded to 0.27 while Bambuddy runs
+            now[0] += TAG_API_RECHECK_SECONDS - 1
+            assert await client.has_tag_api() is False
+            now[0] += 2
+            assert await client.has_tag_api() is True
+
+        assert fake.asked("GET /tag/reader") == 2
+
+
+class TestFindSpoolByTag:
+    async def test_027_answers_with_one_query_and_never_loads_the_inventory(self):
+        fake = FakeSpoolman()
+        for spool_id in range(1, 50):
+            fake.add_spool(spool_id)
+        fake.add_spool(50, tags=[CHIP])
+        client = client_for(fake)
+
+        spool = await client.find_spool_by_tag(CHIP.lower())
+
+        assert spool["id"] == 50
+        assert fake.asked(f"GET /spool?tag={CHIP}") == 1
+        assert fake.asked("GET /spool") == 0
+
+    async def test_a_tag_only_in_extra_tag_is_still_found(self):
+        fake = FakeSpoolman()
+        fake.add_spool(7, extra_tag=TRAY_UUID)
+        client = client_for(fake)
+
+        spool = await client.find_spool_by_tag(TRAY_UUID)
+
+        assert spool["id"] == 7
+        assert fake.asked("GET /spool") == 1
+
+    async def test_a_server_that_ignores_the_tag_parameter_matches_nothing(self):
+        """Spoolman downgraded underneath: ?tag= is ignored and every spool comes back."""
+        fake = FakeSpoolman(tag_api=False)
+        fake.add_spool(3)
+        client = client_for(fake)
+
+        assert await client.find_spool_by_native_tag(CHIP) is None
+
+    async def test_an_older_server_is_searched_through_extra_tag_alone(self):
+        fake = FakeSpoolman(tag_api=False)
+        fake.add_spool(3)
+        fake.add_spool(7, extra_tag=TRAY_UUID)
+        client = client_for(fake)
+
+        spool = await client.find_spool_by_tag(TRAY_UUID)
+
+        assert spool["id"] == 7
+        assert not any("?tag=" in entry for entry in fake.log)
+
+
+class TestLinkNativeTag:
+    async def test_a_free_uid_is_linked(self):
+        fake = FakeSpoolman()
+        fake.add_spool(7)
+        client = client_for(fake)
+
+        assert await client.link_native_tag(7, CHIP) is None
+        assert fake.native(7) == [CHIP]
+
+    async def test_a_uid_another_spool_holds_names_that_spool(self):
+        fake = FakeSpoolman()
+        fake.add_spool(7)
+        fake.add_spool(9, tags=[CHIP])
+        client = client_for(fake)
+
+        assert await client.link_native_tag(7, CHIP) == 9
+        assert fake.native(7) == []
+
+    async def test_a_uid_a_filament_holds_answers_minus_one(self):
+        fake = FakeSpoolman()
+        fake.add_spool(7)
+        fake.filament_tags[CHIP] = 4
+        client = client_for(fake)
+
+        assert await client.link_native_tag(7, CHIP) == -1
+
+
+class TestClaimNativeTag:
+    async def test_a_tag_an_archived_spool_holds_is_taken_from_it(self):
+        fake = FakeSpoolman()
+        fake.add_spool(1, tags=[CHIP], archived=True)
+        fake.add_spool(7)
+        client = client_for(fake)
+
+        assert await client.claim_native_tag(7, CHIP) is None
+        assert (fake.native(1), fake.native(7)) == ([], [CHIP])
+
+    async def test_a_tag_an_active_spool_holds_stays_there(self):
+        fake = FakeSpoolman()
+        fake.add_spool(1, tags=[CHIP])
+        fake.add_spool(7)
+        client = client_for(fake)
+
+        assert await client.claim_native_tag(7, CHIP) == 1
+        assert (fake.native(1), fake.native(7)) == ([CHIP], [])
+
+
+class TestAddNativeTags:
+    async def test_adds_what_is_missing_and_skips_what_cannot_go(self):
+        fake = FakeSpoolman()
+        spool = fake.add_spool(7, tags=[TRAY_UUID])
+        fake.add_spool(9, tags=["AABBCCDD"])
+        client = client_for(fake)
+
+        added = await client.add_native_tags(spool, [f'"{TRAY_UUID}"', CHIP.lower(), "00000000", None, "AABBCCDD"])
+
+        # The tray UUID it had, the zero UID and the missing value are skipped; spool 9
+        # keeps its tag without the call failing.
+        assert added == 1
+        assert fake.native(7) == sorted([TRAY_UUID, CHIP])
+        assert fake.native(9) == ["AABBCCDD"]
+        assert fake.asked("POST /spool/7/tag") == 2
+
+    async def test_an_older_server_gets_no_tag_request(self):
+        fake = FakeSpoolman(tag_api=False)
+        spool = fake.add_spool(7)
+        client = client_for(fake)
+
+        assert await client.add_native_tags(spool, [TRAY_UUID, CHIP]) == 0
+        assert fake.tag_writes() == []
+
+    async def test_a_refused_tag_is_not_asked_for_on_every_update(self, caplog):
+        """The AMS sync calls this on every AMS update; a refusal must not repeat each time."""
+        fake = FakeSpoolman()
+        fake.add_spool(1, tags=[CHIP])
+        spool = fake.add_spool(7)
+        client = client_for(fake)
+        now = [1000.0]
+
+        with patch("backend.app.services.spoolman.time.monotonic", lambda: now[0]):
+            for _ in range(5):
+                await client.add_native_tags(spool, [CHIP])
+                now[0] += 60
+            assert fake.asked("POST /spool/7/tag") == 1
+
+            # The conflict is settled in Spoolman; the next try after the wait picks it up.
+            fake.spools[1]["tags"] = []
+            now[0] += TAG_REFUSAL_RETRY_SECONDS
+            assert await client.add_native_tags(spool, [CHIP]) == 1
+
+        assert fake.native(7) == [CHIP]
+        warnings = [r for r in caplog.records if r.levelname == "WARNING" and CHIP in r.getMessage()]
+        assert len(warnings) == 1
+
+    async def test_a_scan_asks_again_at_once_and_still_holds_off_the_ams_sync(self):
+        fake = FakeSpoolman()
+        fake.add_spool(1, tags=[CHIP])
+        spool = fake.add_spool(7)
+        client = client_for(fake)
+
+        await client.add_native_tags(spool, [CHIP], retry_refused=True)
+        await client.add_native_tags(spool, [CHIP], retry_refused=True)
+        assert fake.asked("POST /spool/7/tag") == 2
+
+        await client.add_native_tags(spool, [CHIP])  # the AMS sync, right after
+        assert fake.asked("POST /spool/7/tag") == 2
+
+
+class TestUnlinkAllNativeTags:
+    async def test_every_tag_of_the_spool_goes(self):
+        fake = FakeSpoolman()
+        spool = fake.add_spool(7, tags=[TRAY_UUID, CHIP])
+        client = client_for(fake)
+
+        await client.unlink_all_native_tags(spool)
+
+        assert fake.native(7) == []
+
+
+class TestAmsSync:
+    async def test_a_known_spool_collects_the_tray_uuid_and_the_chips_own_uid(self):
+        fake = FakeSpoolman()
+        fake.add_spool(7, extra_tag=TRAY_UUID)
+        client = client_for(fake)
+
+        result = await client.sync_ams_tray(_tray(), "X1C", _NoCatalog())
+
+        assert result["id"] == 7
+        assert fake.native(7) == sorted([TRAY_UUID, CHIP])
+        assert CHIP_FROM_AMS not in fake.native(7)
+
+    async def test_a_spool_a_reader_linked_by_its_chip_alone_is_found(self):
+        """SpoolBuddy stores the chip's own 4 bytes; the AMS reports them padded."""
+        fake = FakeSpoolman()
+        fake.add_spool(7, extra_tag=CHIP, tags=[CHIP])
+        client = client_for(fake)
+
+        result = await client.sync_ams_tray(_tray(), "X1C", _NoCatalog(), cached_spools=await client.get_spools())
+
+        assert result["id"] == 7
+        assert fake.native(7) == sorted([TRAY_UUID, CHIP])
+        assert not any(e == "POST /spool" for e in fake.log), "the spool was created a second time"
+
+    async def test_an_older_server_is_synced_without_tag_requests(self):
+        fake = FakeSpoolman(tag_api=False)
+        fake.add_spool(7, extra_tag=TRAY_UUID)
+        client = client_for(fake)
+
+        result = await client.sync_ams_tray(_tray(), "X1C", _NoCatalog())
+
+        assert result["id"] == 7
+        assert fake.tag_writes() == []

+ 94 - 0
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -27,6 +27,7 @@ vi.mock('../../api/client', () => ({
     syncAllPrintersAms: vi.fn(),
     syncPrinterAms: vi.fn(),
     syncSpoolmanAmsWeights: vi.fn(),
+    migrateSpoolmanTags: vi.fn(),
     getPrinters: vi.fn(),
     getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
   },
@@ -388,4 +389,97 @@ describe('SpoolmanSettings', () => {
       });
     });
   });
+
+  describe('native tag migration (Spoolman 0.27+, #3168)', () => {
+    const report = {
+      dry_run: true,
+      moved: [1, 6],
+      already: 38,
+      slot_ids: 1,
+      conflicts: [
+        { spool_id: 4, tag: 'A1B2C3D4', holder: 5 },
+        { spool_id: 9, tag: '11223344', holder: -1 },
+      ],
+    };
+
+    beforeEach(() => {
+      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'auto',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'true',
+      });
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: true,
+        url: 'http://localhost:7912',
+        native_tags: true,
+      });
+      vi.mocked(api.migrateSpoolmanTags).mockImplementation(async (dryRun: boolean) =>
+        dryRun ? report : { ...report, dry_run: false },
+      );
+    });
+
+    it('is not offered by a Spoolman without native tags', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: true,
+        url: 'http://localhost:7912',
+        native_tags: false,
+      });
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Spoolman native tags')).not.toBeInTheDocument();
+    });
+
+    it('checks first and copies nothing until asked to', async () => {
+      const user = userEvent.setup();
+      render(<SpoolmanSettings />);
+
+      await user.click(await screen.findByRole('button', { name: /Check tags/i }));
+
+      expect(await screen.findByText('To copy')).toBeInTheDocument();
+      expect(api.migrateSpoolmanTags).toHaveBeenCalledTimes(1);
+      expect(api.migrateSpoolmanTags).toHaveBeenCalledWith(true);
+      expect(screen.getByText('Spool #4: tag A1B2C3D4 already belongs to spool #5')).toBeInTheDocument();
+      expect(screen.getByText('Spool #9: tag 11223344 already belongs to a filament or location')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Copy tags/i })).toBeInTheDocument();
+    });
+
+    it('copies the tags the check named', async () => {
+      const user = userEvent.setup();
+      render(<SpoolmanSettings />);
+
+      await user.click(await screen.findByRole('button', { name: /Check tags/i }));
+      await user.click(await screen.findByRole('button', { name: /Copy tags/i }));
+
+      await waitFor(() => {
+        expect(api.migrateSpoolmanTags).toHaveBeenLastCalledWith(false);
+      });
+      expect(await screen.findByText('Tags copied to Spoolman: 2')).toBeInTheDocument();
+      // Nothing is left to copy, so the button to do it is gone.
+      expect(screen.queryByRole('button', { name: /Copy tags/i })).not.toBeInTheDocument();
+    });
+
+    it('says so when every tag is already in Spoolman', async () => {
+      vi.mocked(api.migrateSpoolmanTags).mockResolvedValue({
+        dry_run: true,
+        moved: [],
+        already: 39,
+        slot_ids: 0,
+        conflicts: [],
+      });
+      const user = userEvent.setup();
+      render(<SpoolmanSettings />);
+
+      await user.click(await screen.findByRole('button', { name: /Check tags/i }));
+
+      expect(await screen.findByText('Every tag is in Spoolman.')).toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: /Copy tags/i })).not.toBeInTheDocument();
+    });
+  });
 });

+ 13 - 0
frontend/src/api/client.ts

@@ -3710,6 +3710,15 @@ export interface SpoolmanStatus {
   enabled: boolean;
   connected: boolean;
   url: string | null;
+  native_tags?: boolean;  // Spoolman 0.27+ links tags natively
+}
+
+export interface SpoolmanTagMigrationReport {
+  dry_run: boolean;
+  moved: number[];
+  already: number;
+  slot_ids: number;
+  conflicts: Array<{ spool_id: number; tag: string; holder: number }>;  // holder -1: a filament holds the tag
 }
 
 export interface SkippedSpool {
@@ -6833,6 +6842,10 @@ export const api = {
 
   // Spoolman Integration
   getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
+  migrateSpoolmanTags: (dryRun: boolean) =>
+    request<SpoolmanTagMigrationReport>(`/spoolman/inventory/tags/migrate?dry_run=${dryRun}`, {
+      method: 'POST',
+    }),
   connectSpoolman: () =>
     request<{ success: boolean; message: string }>('/spoolman/connect', {
       method: 'POST',

+ 98 - 2
frontend/src/components/SpoolmanSettings.tsx

@@ -1,9 +1,9 @@
 import { useState, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Check, X, RefreshCw, Link2, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react';
+import { Loader2, Check, X, RefreshCw, Link2, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink, Tags } from 'lucide-react';
 import { api, ApiError } from '../api/client';
-import type { SpoolmanSyncResult, Printer } from '../api/client';
+import type { SpoolmanSyncResult, SpoolmanTagMigrationReport, Printer } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
@@ -24,6 +24,7 @@ export function SpoolmanSettings() {
   const [showAllSkipped, setShowAllSkipped] = useState(false);
   const [showAmsSyncConfirm, setShowAmsSyncConfirm] = useState(false);
   const [showSpoolmanAmsSyncConfirm, setShowSpoolmanAmsSyncConfirm] = useState(false);
+  const [tagReport, setTagReport] = useState<SpoolmanTagMigrationReport | null>(null);
 
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
@@ -180,6 +181,21 @@ export function SpoolmanSettings() {
     },
   });
 
+  // Native tag migration (Spoolman 0.27+): a dry run first, the move only from its report
+  const tagMigrationMutation = useMutation({
+    mutationFn: (dryRun: boolean) => api.migrateSpoolmanTags(dryRun),
+    onSuccess: (report) => {
+      setTagReport(report);
+      if (!report.dry_run) {
+        queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
+        showToast(t('settings.nativeTagsMoved', { count: report.moved.length }), 'success');
+      }
+    },
+    onError: () => {
+      showToast(t('settings.nativeTagsError'), 'error');
+    },
+  });
+
   // Combine mutation states
   const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
   const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
@@ -550,6 +566,86 @@ export function SpoolmanSettings() {
                 </div>
               )}
 
+              {/* Native tags (Spoolman 0.27+) */}
+              {status?.connected && status.native_tags && (
+                <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3">
+                  <div className="flex items-center justify-between">
+                    <div className="pr-4">
+                      <p className="text-sm text-white">{t('settings.nativeTagsTitle')}</p>
+                      <p className="text-xs text-bambu-gray">{t('settings.nativeTagsDesc')}</p>
+                    </div>
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={() => tagMigrationMutation.mutate(true)}
+                      disabled={tagMigrationMutation.isPending}
+                      className="flex-shrink-0 whitespace-nowrap"
+                    >
+                      {tagMigrationMutation.isPending && tagMigrationMutation.variables ? (
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                      ) : (
+                        <Tags className="w-4 h-4" />
+                      )}
+                      {t('settings.nativeTagsCheck')}
+                    </Button>
+                  </div>
+
+                  {tagReport && (
+                    <div className="p-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm space-y-2">
+                      <dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-0.5 text-xs">
+                        <dt className="text-bambu-gray">{t('settings.nativeTagsToMove')}</dt>
+                        <dd className="text-white text-right">{tagReport.dry_run ? tagReport.moved.length : 0}</dd>
+                        <dt className="text-bambu-gray">{t('settings.nativeTagsAlready')}</dt>
+                        <dd className="text-white text-right">
+                          {tagReport.already + (tagReport.dry_run ? 0 : tagReport.moved.length)}
+                        </dd>
+                        <dt className="text-bambu-gray">{t('settings.nativeTagsSlotIds')}</dt>
+                        <dd className="text-white text-right">{tagReport.slot_ids}</dd>
+                        <dt className="text-bambu-gray">{t('settings.nativeTagsConflicts')}</dt>
+                        <dd className="text-white text-right">{tagReport.conflicts.length}</dd>
+                      </dl>
+
+                      {tagReport.conflicts.length > 0 && (
+                        <ul className="p-2 bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/30 rounded text-xs text-amber-700 dark:text-amber-400 space-y-0.5">
+                          {tagReport.conflicts.map((c) => (
+                            <li key={`${c.spool_id}-${c.tag}`} className="flex items-start gap-1.5">
+                              <AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
+                              <span>
+                                {c.holder > 0
+                                  ? t('settings.nativeTagsConflictSpool', { spool: c.spool_id, tag: c.tag, holder: c.holder })
+                                  : t('settings.nativeTagsConflictElsewhere', { spool: c.spool_id, tag: c.tag })}
+                              </span>
+                            </li>
+                          ))}
+                        </ul>
+                      )}
+
+                      {tagReport.dry_run && tagReport.moved.length > 0 ? (
+                        <Button
+                          size="sm"
+                          onClick={() => tagMigrationMutation.mutate(false)}
+                          disabled={tagMigrationMutation.isPending}
+                        >
+                          {tagMigrationMutation.isPending && !tagMigrationMutation.variables ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            <Tags className="w-4 h-4" />
+                          )}
+                          {t('settings.nativeTagsMove')}
+                        </Button>
+                      ) : (
+                        tagReport.conflicts.length === 0 && (
+                          <p className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
+                            <Check className="w-3.5 h-3.5" />
+                            {t('settings.nativeTagsNothing')}
+                          </p>
+                        )
+                      )}
+                    </div>
+                  )}
+                </div>
+              )}
+
               {/* Sync result */}
               {syncSuccess && syncResult && (
                 <div className="mt-3 space-y-2">

+ 13 - 0
frontend/src/i18n/locales/de.ts

@@ -2239,6 +2239,19 @@ export default {
     spoolmanAmsSyncError: 'Synchronisierung der Spoolman-Gewichte vom AMS fehlgeschlagen',
     spoolmanAmsSyncErrorUnreachable: 'Synchronisierung fehlgeschlagen (Spoolman nicht erreichbar)',
     spoolmanAmsSyncErrorNotConfigured: 'Synchronisierung fehlgeschlagen (Spoolman nicht konfiguriert)',
+    nativeTagsTitle: 'Native Tags in Spoolman',
+    nativeTagsDesc: 'Spoolman 0.27 verknüpft NFC-Tags selbst. Kopiert die Tags, die Bambuddy in extra.tag führt, dorthin, damit Spoolman und andere Lesegeräte die Spulen ebenfalls finden. extra.tag bleibt unverändert.',
+    nativeTagsCheck: 'Tags prüfen',
+    nativeTagsToMove: 'Zu kopieren',
+    nativeTagsAlready: 'Schon in Spoolman',
+    nativeTagsSlotIds: 'AMS-Slot-IDs (bleiben in extra.tag)',
+    nativeTagsConflicts: 'Konflikte',
+    nativeTagsConflictSpool: 'Spule #{{spool}}: Tag {{tag}} gehört bereits zu Spule #{{holder}}',
+    nativeTagsConflictElsewhere: 'Spule #{{spool}}: Tag {{tag}} gehört bereits zu einem Filament oder Lagerort',
+    nativeTagsMove: 'Tags kopieren',
+    nativeTagsMoved: 'Nach Spoolman kopierte Tags: {{count}}',
+    nativeTagsNothing: 'Alle Tags sind in Spoolman.',
+    nativeTagsError: 'Tags konnten nicht nach Spoolman kopiert werden',
     spoolmanNotConfigured: 'Spoolman nicht konfiguriert',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Spoolman-Filamentkatalog',

+ 13 - 0
frontend/src/i18n/locales/en.ts

@@ -2261,6 +2261,19 @@ export default {
     spoolmanAmsSyncError: 'Failed to sync Spoolman weights from AMS',
     spoolmanAmsSyncErrorUnreachable: 'Failed to sync Spoolman weights (Spoolman unreachable)',
     spoolmanAmsSyncErrorNotConfigured: 'Failed to sync Spoolman weights (Spoolman not configured)',
+    nativeTagsTitle: 'Spoolman native tags',
+    nativeTagsDesc: 'Spoolman 0.27 links NFC tags itself. Copy the tags Bambuddy keeps in extra.tag over, so Spoolman and other readers find the spools too. extra.tag stays as it is.',
+    nativeTagsCheck: 'Check tags',
+    nativeTagsToMove: 'To copy',
+    nativeTagsAlready: 'Already in Spoolman',
+    nativeTagsSlotIds: 'AMS slot IDs (stay in extra.tag)',
+    nativeTagsConflicts: 'Conflicts',
+    nativeTagsConflictSpool: 'Spool #{{spool}}: tag {{tag}} already belongs to spool #{{holder}}',
+    nativeTagsConflictElsewhere: 'Spool #{{spool}}: tag {{tag}} already belongs to a filament or location',
+    nativeTagsMove: 'Copy tags',
+    nativeTagsMoved: 'Tags copied to Spoolman: {{count}}',
+    nativeTagsNothing: 'Every tag is in Spoolman.',
+    nativeTagsError: 'Could not copy the tags to Spoolman',
     spoolmanNotConfigured: 'Spoolman not configured',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Spoolman Filament Catalog',

+ 13 - 0
frontend/src/i18n/locales/es.ts

@@ -2242,6 +2242,19 @@ export default {
     spoolmanAmsSyncError: 'Error al sincronizar los pesos de Spoolman desde el AMS',
     spoolmanAmsSyncErrorUnreachable: 'Error al sincronizar los pesos de Spoolman (Spoolman inaccesible)',
     spoolmanAmsSyncErrorNotConfigured: 'Error al sincronizar los pesos de Spoolman (Spoolman no configurado)',
+    nativeTagsTitle: 'Etiquetas nativas de Spoolman',
+    nativeTagsDesc: 'Spoolman 0.27 vincula las etiquetas NFC por sí mismo. Copia allí las etiquetas que Bambuddy guarda en extra.tag para que Spoolman y otros lectores también encuentren las bobinas. extra.tag no se modifica.',
+    nativeTagsCheck: 'Comprobar etiquetas',
+    nativeTagsToMove: 'Por copiar',
+    nativeTagsAlready: 'Ya en Spoolman',
+    nativeTagsSlotIds: 'ID de ranura AMS (se quedan en extra.tag)',
+    nativeTagsConflicts: 'Conflictos',
+    nativeTagsConflictSpool: 'Bobina #{{spool}}: la etiqueta {{tag}} ya pertenece a la bobina #{{holder}}',
+    nativeTagsConflictElsewhere: 'Bobina #{{spool}}: la etiqueta {{tag}} ya pertenece a un filamento o una ubicación',
+    nativeTagsMove: 'Copiar etiquetas',
+    nativeTagsMoved: 'Etiquetas copiadas a Spoolman: {{count}}',
+    nativeTagsNothing: 'Todas las etiquetas están en Spoolman.',
+    nativeTagsError: 'No se pudieron copiar las etiquetas a Spoolman',
     spoolmanNotConfigured: 'Spoolman no configurado',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Catálogo de filamentos de Spoolman',

+ 13 - 0
frontend/src/i18n/locales/fr.ts

@@ -2194,6 +2194,19 @@ export default {
     spoolmanAmsSyncError: 'Échec de la synchronisation des poids Spoolman depuis l\'AMS',
     spoolmanAmsSyncErrorUnreachable: 'Échec de la synchronisation (Spoolman inaccessible)',
     spoolmanAmsSyncErrorNotConfigured: 'Échec de la synchronisation (Spoolman non configuré)',
+    nativeTagsTitle: 'Tags natifs de Spoolman',
+    nativeTagsDesc: 'Spoolman 0.27 associe lui-même les tags NFC. Copiez-y les tags que Bambuddy conserve dans extra.tag, afin que Spoolman et les autres lecteurs trouvent aussi les bobines. extra.tag reste inchangé.',
+    nativeTagsCheck: 'Vérifier les tags',
+    nativeTagsToMove: 'À copier',
+    nativeTagsAlready: 'Déjà dans Spoolman',
+    nativeTagsSlotIds: 'ID d’emplacement AMS (restent dans extra.tag)',
+    nativeTagsConflicts: 'Conflits',
+    nativeTagsConflictSpool: 'Bobine #{{spool}} : le tag {{tag}} appartient déjà à la bobine #{{holder}}',
+    nativeTagsConflictElsewhere: 'Bobine #{{spool}} : le tag {{tag}} appartient déjà à un filament ou à un emplacement',
+    nativeTagsMove: 'Copier les tags',
+    nativeTagsMoved: 'Tags copiés dans Spoolman : {{count}}',
+    nativeTagsNothing: 'Tous les tags sont dans Spoolman.',
+    nativeTagsError: 'Impossible de copier les tags dans Spoolman',
     spoolmanNotConfigured: 'Spoolman non configuré',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Catalogue de filaments Spoolman',

+ 13 - 0
frontend/src/i18n/locales/it.ts

@@ -2194,6 +2194,19 @@ export default {
     spoolmanAmsSyncError: 'Impossibile sincronizzare i pesi Spoolman dall\'AMS',
     spoolmanAmsSyncErrorUnreachable: 'Sincronizzazione fallita (Spoolman non raggiungibile)',
     spoolmanAmsSyncErrorNotConfigured: 'Sincronizzazione fallita (Spoolman non configurato)',
+    nativeTagsTitle: 'Tag nativi di Spoolman',
+    nativeTagsDesc: 'Spoolman 0.27 collega da solo i tag NFC. Copia lì i tag che Bambuddy conserva in extra.tag, così anche Spoolman e gli altri lettori trovano le bobine. extra.tag resta invariato.',
+    nativeTagsCheck: 'Verifica tag',
+    nativeTagsToMove: 'Da copiare',
+    nativeTagsAlready: 'Già in Spoolman',
+    nativeTagsSlotIds: 'ID slot AMS (restano in extra.tag)',
+    nativeTagsConflicts: 'Conflitti',
+    nativeTagsConflictSpool: 'Bobina #{{spool}}: il tag {{tag}} appartiene già alla bobina #{{holder}}',
+    nativeTagsConflictElsewhere: 'Bobina #{{spool}}: il tag {{tag}} appartiene già a un filamento o a una posizione',
+    nativeTagsMove: 'Copia tag',
+    nativeTagsMoved: 'Tag copiati in Spoolman: {{count}}',
+    nativeTagsNothing: 'Tutti i tag sono in Spoolman.',
+    nativeTagsError: 'Impossibile copiare i tag in Spoolman',
     spoolmanNotConfigured: 'Spoolman non configurato',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Catalogo filamenti Spoolman',

+ 13 - 0
frontend/src/i18n/locales/ja.ts

@@ -2238,6 +2238,19 @@ export default {
     spoolmanAmsSyncError: 'AMSからのSpoolman重量同期に失敗しました',
     spoolmanAmsSyncErrorUnreachable: '同期失敗(Spoolmanに接続できません)',
     spoolmanAmsSyncErrorNotConfigured: '同期失敗(Spoolmanが設定されていません)',
+    nativeTagsTitle: 'Spoolman ネイティブタグ',
+    nativeTagsDesc: 'Spoolman 0.27 は NFC タグを自身で紐付けます。Bambuddy が extra.tag に保持しているタグをそちらへコピーすると、Spoolman や他のリーダーでもスプールが見つかります。extra.tag はそのまま残ります。',
+    nativeTagsCheck: 'タグを確認',
+    nativeTagsToMove: 'コピー対象',
+    nativeTagsAlready: 'Spoolman に登録済み',
+    nativeTagsSlotIds: 'AMS スロット ID(extra.tag に残ります)',
+    nativeTagsConflicts: '競合',
+    nativeTagsConflictSpool: 'スプール #{{spool}}: タグ {{tag}} は既にスプール #{{holder}} に紐付いています',
+    nativeTagsConflictElsewhere: 'スプール #{{spool}}: タグ {{tag}} は既にフィラメントまたは保管場所に紐付いています',
+    nativeTagsMove: 'タグをコピー',
+    nativeTagsMoved: 'Spoolman にコピーしたタグ: {{count}}',
+    nativeTagsNothing: 'すべてのタグが Spoolman にあります。',
+    nativeTagsError: 'タグを Spoolman にコピーできませんでした',
     spoolmanNotConfigured: 'Spoolmanが設定されていません',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Spoolmanフィラメントカタログ',

+ 13 - 0
frontend/src/i18n/locales/ko.ts

@@ -2143,6 +2143,19 @@ export default {
     spoolmanAmsSyncError: 'AMS에서 Spoolman 무게 동기화 실패',
     spoolmanAmsSyncErrorUnreachable: 'Spoolman 무게 동기화 실패 (Spoolman 접근 불가)',
     spoolmanAmsSyncErrorNotConfigured: 'Spoolman 무게 동기화 실패 (Spoolman 미설정)',
+    nativeTagsTitle: 'Spoolman 기본 태그',
+    nativeTagsDesc: 'Spoolman 0.27은 NFC 태그를 직접 연결합니다. Bambuddy가 extra.tag에 보관한 태그를 그곳으로 복사하면 Spoolman과 다른 리더에서도 스풀을 찾을 수 있습니다. extra.tag는 그대로 유지됩니다.',
+    nativeTagsCheck: '태그 확인',
+    nativeTagsToMove: '복사 대상',
+    nativeTagsAlready: '이미 Spoolman에 있음',
+    nativeTagsSlotIds: 'AMS 슬롯 ID (extra.tag에 유지)',
+    nativeTagsConflicts: '충돌',
+    nativeTagsConflictSpool: '스풀 #{{spool}}: 태그 {{tag}}는 이미 스풀 #{{holder}}에 연결되어 있습니다',
+    nativeTagsConflictElsewhere: '스풀 #{{spool}}: 태그 {{tag}}는 이미 필라멘트 또는 위치에 연결되어 있습니다',
+    nativeTagsMove: '태그 복사',
+    nativeTagsMoved: 'Spoolman에 복사된 태그: {{count}}',
+    nativeTagsNothing: '모든 태그가 Spoolman에 있습니다.',
+    nativeTagsError: '태그를 Spoolman에 복사하지 못했습니다',
     spoolmanNotConfigured: 'Spoolman 미설정',
     spoolmanFilamentCatalogTitle: 'Spoolman 필라멘트 카탈로그',
     spoolmanFilamentCatalogDesc: 'Spoolman 인스턴스의 필라멘트 이름 및 공 무게. 이름과 스풀 무게는 여기서 편집 가능하며 다른 속성은 Spoolman에서 직접 관리됩니다.',

+ 13 - 0
frontend/src/i18n/locales/nl.ts

@@ -2259,6 +2259,19 @@ export default {
     spoolmanAmsSyncError: 'Spoolman-gewichten synchroniseren vanuit AMS mislukt',
     spoolmanAmsSyncErrorUnreachable: 'Spoolman-gewichten synchroniseren mislukt (Spoolman niet bereikbaar)',
     spoolmanAmsSyncErrorNotConfigured: 'Spoolman-gewichten synchroniseren mislukt (Spoolman niet geconfigureerd)',
+    nativeTagsTitle: 'Native tags in Spoolman',
+    nativeTagsDesc: 'Spoolman 0.27 koppelt NFC-tags zelf. Kopieer de tags die Bambuddy in extra.tag bewaart daarheen, zodat Spoolman en andere lezers de spoelen ook vinden. extra.tag blijft ongewijzigd.',
+    nativeTagsCheck: 'Tags controleren',
+    nativeTagsToMove: 'Te kopiëren',
+    nativeTagsAlready: 'Al in Spoolman',
+    nativeTagsSlotIds: "AMS-slot-ID's (blijven in extra.tag)",
+    nativeTagsConflicts: 'Conflicten',
+    nativeTagsConflictSpool: 'Spoel #{{spool}}: tag {{tag}} hoort al bij spoel #{{holder}}',
+    nativeTagsConflictElsewhere: 'Spoel #{{spool}}: tag {{tag}} hoort al bij een filament of locatie',
+    nativeTagsMove: 'Tags kopiëren',
+    nativeTagsMoved: 'Naar Spoolman gekopieerde tags: {{count}}',
+    nativeTagsNothing: 'Alle tags staan in Spoolman.',
+    nativeTagsError: 'De tags konden niet naar Spoolman worden gekopieerd',
     spoolmanNotConfigured: 'Spoolman niet geconfigureerd',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Spoolman-filamentcatalogus',

+ 13 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2194,6 +2194,19 @@ export default {
     spoolmanAmsSyncError: 'Falha ao sincronizar pesos do Spoolman pelo AMS',
     spoolmanAmsSyncErrorUnreachable: 'Falha na sincronização (Spoolman inacessível)',
     spoolmanAmsSyncErrorNotConfigured: 'Falha na sincronização (Spoolman não configurado)',
+    nativeTagsTitle: 'Tags nativas do Spoolman',
+    nativeTagsDesc: 'O Spoolman 0.27 vincula as tags NFC por conta própria. Copie para lá as tags que o Bambuddy mantém em extra.tag, para que o Spoolman e outros leitores também encontrem os carretéis. O extra.tag permanece como está.',
+    nativeTagsCheck: 'Verificar tags',
+    nativeTagsToMove: 'A copiar',
+    nativeTagsAlready: 'Já no Spoolman',
+    nativeTagsSlotIds: 'IDs de slot do AMS (ficam em extra.tag)',
+    nativeTagsConflicts: 'Conflitos',
+    nativeTagsConflictSpool: 'Carretel #{{spool}}: a tag {{tag}} já pertence ao carretel #{{holder}}',
+    nativeTagsConflictElsewhere: 'Carretel #{{spool}}: a tag {{tag}} já pertence a um filamento ou local',
+    nativeTagsMove: 'Copiar tags',
+    nativeTagsMoved: 'Tags copiadas para o Spoolman: {{count}}',
+    nativeTagsNothing: 'Todas as tags estão no Spoolman.',
+    nativeTagsError: 'Não foi possível copiar as tags para o Spoolman',
     spoolmanNotConfigured: 'Spoolman não configurado',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Catálogo de filamentos Spoolman',

+ 13 - 0
frontend/src/i18n/locales/ru.ts

@@ -2140,6 +2140,19 @@ export default {
     spoolmanAmsSyncError: "Не удалось синхронизировать вес Spoolman из AMS",
     spoolmanAmsSyncErrorUnreachable: "Не удалось синхронизировать вес Spoolman: сервер недоступен",
     spoolmanAmsSyncErrorNotConfigured: "Не удалось синхронизировать вес Spoolman: сервер не настроен",
+    nativeTagsTitle: 'Встроенные метки Spoolman',
+    nativeTagsDesc: 'Spoolman 0.27 сам привязывает NFC-метки. Скопируйте туда метки, которые Bambuddy хранит в extra.tag, чтобы Spoolman и другие считыватели тоже находили катушки. extra.tag остаётся без изменений.',
+    nativeTagsCheck: 'Проверить метки',
+    nativeTagsToMove: 'К копированию',
+    nativeTagsAlready: 'Уже в Spoolman',
+    nativeTagsSlotIds: 'ID слотов AMS (остаются в extra.tag)',
+    nativeTagsConflicts: 'Конфликты',
+    nativeTagsConflictSpool: 'Катушка #{{spool}}: метка {{tag}} уже принадлежит катушке #{{holder}}',
+    nativeTagsConflictElsewhere: 'Катушка #{{spool}}: метка {{tag}} уже принадлежит филаменту или месту хранения',
+    nativeTagsMove: 'Скопировать метки',
+    nativeTagsMoved: 'Меток скопировано в Spoolman: {{count}}',
+    nativeTagsNothing: 'Все метки уже в Spoolman.',
+    nativeTagsError: 'Не удалось скопировать метки в Spoolman',
     spoolmanNotConfigured: "Spoolman не настроен",
     spoolmanFilamentCatalogTitle: "Каталог филамента Spoolman",
     spoolmanFilamentCatalogDesc: "Названия филаментов и масса пустых катушек из Spoolman. Здесь можно изменить название и массу катушки; остальные свойства редактируются непосредственно в Spoolman.",

+ 13 - 0
frontend/src/i18n/locales/sv.ts

@@ -2258,6 +2258,19 @@ export default {
     spoolmanAmsSyncError: 'Kunde inte synkronisera Spoolman-vikter från AMS',
     spoolmanAmsSyncErrorUnreachable: 'Kunde inte synkronisera Spoolman-vikter (Spoolman otillgänglig)',
     spoolmanAmsSyncErrorNotConfigured: 'Kunde inte synkronisera Spoolman-vikter (Spoolman ej konfigurerad)',
+    nativeTagsTitle: 'Inbyggda taggar i Spoolman',
+    nativeTagsDesc: 'Spoolman 0.27 kopplar NFC-taggar själv. Kopiera dit taggarna som Bambuddy sparar i extra.tag, så att Spoolman och andra läsare också hittar spolarna. extra.tag lämnas som den är.',
+    nativeTagsCheck: 'Kontrollera taggar',
+    nativeTagsToMove: 'Att kopiera',
+    nativeTagsAlready: 'Redan i Spoolman',
+    nativeTagsSlotIds: 'AMS-plats-ID:n (stannar i extra.tag)',
+    nativeTagsConflicts: 'Konflikter',
+    nativeTagsConflictSpool: 'Spole #{{spool}}: taggen {{tag}} tillhör redan spole #{{holder}}',
+    nativeTagsConflictElsewhere: 'Spole #{{spool}}: taggen {{tag}} tillhör redan ett filament eller en plats',
+    nativeTagsMove: 'Kopiera taggar',
+    nativeTagsMoved: 'Taggar kopierade till Spoolman: {{count}}',
+    nativeTagsNothing: 'Alla taggar finns i Spoolman.',
+    nativeTagsError: 'Det gick inte att kopiera taggarna till Spoolman',
     spoolmanNotConfigured: 'Spoolman ej konfigurerad',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Spoolman filamentkatalog',

+ 13 - 0
frontend/src/i18n/locales/tr.ts

@@ -2242,6 +2242,19 @@ export default {
     spoolmanAmsSyncError: 'Spoolman ağırlıkları AMS\'den senkronize edilemedi',
     spoolmanAmsSyncErrorUnreachable: 'Spoolman ağırlıkları senkronize edilemedi (Spoolman erişilemez)',
     spoolmanAmsSyncErrorNotConfigured: 'Spoolman ağırlıkları senkronize edilemedi (Spoolman yapılandırılmamış)',
+    nativeTagsTitle: 'Spoolman yerel etiketleri',
+    nativeTagsDesc: "Spoolman 0.27 NFC etiketlerini kendisi bağlar. Bambuddy'nin extra.tag içinde tuttuğu etiketleri oraya kopyalayın; böylece Spoolman ve diğer okuyucular da makaraları bulur. extra.tag olduğu gibi kalır.",
+    nativeTagsCheck: 'Etiketleri kontrol et',
+    nativeTagsToMove: 'Kopyalanacak',
+    nativeTagsAlready: "Zaten Spoolman'da",
+    nativeTagsSlotIds: 'AMS yuva kimlikleri (extra.tag içinde kalır)',
+    nativeTagsConflicts: 'Çakışmalar',
+    nativeTagsConflictSpool: 'Makara #{{spool}}: {{tag}} etiketi zaten #{{holder}} makarasına ait',
+    nativeTagsConflictElsewhere: 'Makara #{{spool}}: {{tag}} etiketi zaten bir filamente veya konuma ait',
+    nativeTagsMove: 'Etiketleri kopyala',
+    nativeTagsMoved: "Spoolman'a kopyalanan etiketler: {{count}}",
+    nativeTagsNothing: "Tüm etiketler Spoolman'da.",
+    nativeTagsError: "Etiketler Spoolman'a kopyalanamadı",
     spoolmanNotConfigured: 'Spoolman yapılandırılmamış',
     // Makara kataloğu ayarlarında Spoolman filament kataloğu bölümü
     spoolmanFilamentCatalogTitle: 'Spoolman Filament Kataloğu',

+ 13 - 0
frontend/src/i18n/locales/uk.ts

@@ -2257,6 +2257,19 @@ export default {
     spoolmanAmsSyncError: "Не вдалося синхронізувати масу котушок Spoolman з AMS",
     spoolmanAmsSyncErrorUnreachable: "Не вдалося синхронізувати масу котушок Spoolman: сервер Spoolman недоступний",
     spoolmanAmsSyncErrorNotConfigured: "Не вдалося синхронізувати масу котушок Spoolman: сервер Spoolman не налаштовано",
+    nativeTagsTitle: 'Вбудовані мітки Spoolman',
+    nativeTagsDesc: 'Spoolman 0.27 сам прив’язує NFC-мітки. Скопіюйте туди мітки, які Bambuddy зберігає в extra.tag, щоб Spoolman та інші зчитувачі теж знаходили котушки. extra.tag залишається без змін.',
+    nativeTagsCheck: 'Перевірити мітки',
+    nativeTagsToMove: 'До копіювання',
+    nativeTagsAlready: 'Вже в Spoolman',
+    nativeTagsSlotIds: 'ID слотів AMS (залишаються в extra.tag)',
+    nativeTagsConflicts: 'Конфлікти',
+    nativeTagsConflictSpool: 'Котушка #{{spool}}: мітка {{tag}} вже належить котушці #{{holder}}',
+    nativeTagsConflictElsewhere: 'Котушка #{{spool}}: мітка {{tag}} вже належить філаменту або місцю зберігання',
+    nativeTagsMove: 'Скопіювати мітки',
+    nativeTagsMoved: 'Міток скопійовано в Spoolman: {{count}}',
+    nativeTagsNothing: 'Усі мітки вже в Spoolman.',
+    nativeTagsError: 'Не вдалося скопіювати мітки в Spoolman',
     spoolmanNotConfigured: "Spoolman не налаштовано",
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: "Каталог філаментів Spoolman",

+ 13 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2239,6 +2239,19 @@ export default {
     spoolmanAmsSyncError: '从 AMS 同步 Spoolman 重量失败',
     spoolmanAmsSyncErrorUnreachable: '同步失败(Spoolman 不可访问)',
     spoolmanAmsSyncErrorNotConfigured: '同步失败(Spoolman 未配置)',
+    nativeTagsTitle: 'Spoolman 原生标签',
+    nativeTagsDesc: 'Spoolman 0.27 可自行关联 NFC 标签。将 Bambuddy 保存在 extra.tag 中的标签复制过去,Spoolman 和其他读卡器也能找到这些料盘。extra.tag 保持不变。',
+    nativeTagsCheck: '检查标签',
+    nativeTagsToMove: '待复制',
+    nativeTagsAlready: '已在 Spoolman 中',
+    nativeTagsSlotIds: 'AMS 槽位 ID(保留在 extra.tag 中)',
+    nativeTagsConflicts: '冲突',
+    nativeTagsConflictSpool: '料盘 #{{spool}}:标签 {{tag}} 已属于料盘 #{{holder}}',
+    nativeTagsConflictElsewhere: '料盘 #{{spool}}:标签 {{tag}} 已属于某个耗材或存放位置',
+    nativeTagsMove: '复制标签',
+    nativeTagsMoved: '已复制到 Spoolman 的标签:{{count}}',
+    nativeTagsNothing: '所有标签都已在 Spoolman 中。',
+    nativeTagsError: '无法将标签复制到 Spoolman',
     spoolmanNotConfigured: 'Spoolman 未配置',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Spoolman 耗材目录',

+ 13 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2239,6 +2239,19 @@ export default {
     spoolmanAmsSyncError: '從 AMS 同步 Spoolman 重量失敗',
     spoolmanAmsSyncErrorUnreachable: '同步失敗(Spoolman 無法存取)',
     spoolmanAmsSyncErrorNotConfigured: '同步失敗(Spoolman 未設定)',
+    nativeTagsTitle: 'Spoolman 原生標籤',
+    nativeTagsDesc: 'Spoolman 0.27 可自行關聯 NFC 標籤。將 Bambuddy 保存在 extra.tag 中的標籤複製過去,Spoolman 和其他讀取器也能找到這些料盤。extra.tag 保持不變。',
+    nativeTagsCheck: '檢查標籤',
+    nativeTagsToMove: '待複製',
+    nativeTagsAlready: '已在 Spoolman 中',
+    nativeTagsSlotIds: 'AMS 槽位 ID(保留在 extra.tag 中)',
+    nativeTagsConflicts: '衝突',
+    nativeTagsConflictSpool: '料盤 #{{spool}}:標籤 {{tag}} 已屬於料盤 #{{holder}}',
+    nativeTagsConflictElsewhere: '料盤 #{{spool}}:標籤 {{tag}} 已屬於某個耗材或存放位置',
+    nativeTagsMove: '複製標籤',
+    nativeTagsMoved: '已複製到 Spoolman 的標籤:{{count}}',
+    nativeTagsNothing: '所有標籤都已在 Spoolman 中。',
+    nativeTagsError: '無法將標籤複製到 Spoolman',
     spoolmanNotConfigured: 'Spoolman 未設定',
     // Spoolman filament catalog section in spool catalog settings
     spoolmanFilamentCatalogTitle: 'Spoolman 耗材目錄',