瀏覽代碼

fix(spoolbuddy): writing a sticker no longer replaces a Bambu spool's AMS link in Spoolman (refused with the reason on Spoolman before 0.27, and such spools are not listed as untagged); the colour-name warning only appears when the name is really missing; an update stops when installing dependencies times out instead of restarting the daemon and reporting success

maziggy 2 天之前
父節點
當前提交
ba93abdb9d

+ 3 - 0
CHANGELOG.md

@@ -164,6 +164,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **SpoolBuddy: writing a sticker could break a Bambu spool's AMS link in Spoolman mode** — A Bambu spool created from the AMS is linked by its RFID tray ID; it was listed as untagged on the Write Tag page, and a sticker written to it replaced that link in Spoolman, so the next AMS sync created a duplicate spool. It is no longer listed as untagged, and on a Spoolman that does not also keep the tray ID as a native tag (before 0.27) the write is refused with the reason.
+- **SpoolBuddy: Write Tag warned "color_name not set" for every Spoolman spool** — The warning looked at a Spoolman field that is never filled; Bambuddy keeps the colour name with the spool. It now warns only when the name is missing, and says the tag then carries the subtype instead.
+- **SpoolBuddy: an update reported success after installing its dependencies timed out** — The device was restarted while the install could still be running. A timeout now stops the update with an error (and the time allowed went from 2 to 5 minutes); a failed install still completes, and its output is now in the log.
 - **Virtual printer: a late slicer update could rewrite a queue job already being sent** — The slicer's print options can reach a virtual printer seconds after the job was queued. If the scheduler had already started sending the job, its AMS mapping and options were still rewritten, so the queue entry -- and the spool charged from it -- could differ from what the printer printed. Such a job is now left as it was sent.
 - **Backup restore: the virtual printers kept running during the restore** — The step meant to stop them did nothing, so their FTP, MQTT and discovery services stayed up while files were replaced. They are now stopped, and start again with the restart a restore asks for.
 - **API: the legacy virtual printer settings route answered 200 for settings it no longer applies** — `PUT /settings/virtual-printer` saved enabled, access code, mode, model, target printer and remote interface to settings nothing reads (each virtual printer keeps its own). It now refuses them with a pointer to `PUT /virtual-printers/{id}`; the archive name source and Tailscale switch still work there.

+ 27 - 9
backend/app/api/routes/spoolbuddy.py

@@ -4,6 +4,7 @@ import asyncio
 import contextlib
 import json
 import logging
+import re
 import time
 from datetime import datetime, timedelta, timezone
 from urllib.parse import urlparse
@@ -708,19 +709,36 @@ async def nfc_write_tag(
                 detail="Spoolman spool has no material set — cannot encode NFC tag",
             )
 
+        # A Bambu spool created from the AMS is linked by its tray UUID in
+        # extra.tag; a sticker would replace it there, and the next AMS sync,
+        # no longer finding the spool, would create a duplicate (SM-C5). Not
+        # when Spoolman also keeps that UUID as a native tag (0.27+): the sync
+        # finds the spool by it first, and the sticker leaves it alone.
+        from backend.app.api.routes._spoolman_helpers import _extract_extra_str
+
+        extra_tag = _extract_extra_str(sm_spool.get("extra") or {}, "tag").strip().upper()
+        native_uids = {str(t.get("uid", "")).upper() for t in sm_spool.get("tags") or [] if isinstance(t, dict)}
+        if re.fullmatch(r"[0-9A-F]{32}", extra_tag) and extra_tag not in native_uids:
+            raise HTTPException(
+                status_code=409,
+                detail="This spool is linked to its Bambu RFID tag through the AMS; "
+                "a sticker would replace that link in Spoolman",
+            )
+
         ndef_data = encode_opentag3d_from_mapped(mapped)
         data_origin = "spoolman"
 
         # Warn when fields that drive NFC content are absent in Spoolman.
-        # color_name specifically must check the raw filament field, not the
-        # mapped value — _map_spoolman_spool falls back to the filament's
-        # subtype when color_name is unset (so LinkSpoolModal stops showing
-        # "Unknown color"), but the NFC tag should still warn when Spoolman
-        # has no genuine color_name on file. Without this, the fallback
-        # silently masks a real missing-data condition.
-        raw_filament: dict = sm_spool.get("filament") or {}
-        if not raw_filament.get("color_name"):
-            nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name")
+        # The colour name is read the way the tag gets it: from the spool's
+        # extra (where Bambuddy keeps it -- Spoolman's filament has no such
+        # field, so checking that warned on every spool, SM-C6), else the
+        # subtype stands in for it.
+        if mapped.get("color_name") is None:
+            nfc_warnings.append("color_name not set in Spoolman — tag encodes an empty color name")
+        elif mapped.get("color_name_is_synthesized"):
+            nfc_warnings.append(
+                f"color_name not set in Spoolman — tag encodes the subtype ({mapped['color_name']}) as its color name"
+            )
         if not mapped.get("nozzle_temp_min"):
             nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
         if not mapped.get("subtype"):

+ 23 - 4
backend/app/services/spoolbuddy_ssh.py

@@ -26,6 +26,8 @@ from backend.app.core.config import settings
 
 logger = logging.getLogger(__name__)
 
+PIP_INSTALL_TIMEOUT = 300
+
 SSH_USER = "spoolbuddy"
 DEFAULT_INSTALL_PATH = "/opt/bambuddy"
 
@@ -310,15 +312,32 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
         # Step 4: Install dependencies
         await _update_progress("updating", "Installing dependencies...")
         venv_pip = shlex.quote(f"{install_path}/spoolbuddy/venv/bin/pip")
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, pip_output, stderr, _ = await _run_ssh_command(
             ip_address,
             f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
             private_key,
             known_hosts=known_hosts,
-            timeout=120,
+            # A slow Pi building wheels needs more than two minutes.
+            timeout=PIP_INSTALL_TIMEOUT,
         )
+        deps_note = ""
+        if rc == -1:
+            # Timed out here, not necessarily on the Pi: pip may still be
+            # installing, and restarting the daemon over a half-installed venv
+            # (then reporting success) is how a broken update looked fine (TO-C12).
+            await _update_progress(
+                "error",
+                "Installing dependencies timed out; the daemon was not restarted. "
+                "Run the update again once the device has finished.",
+            )
+            return
         if rc != 0:
-            logger.warning("SpoolBuddy %s: pip install returned non-zero (continuing): %s", device_id, stderr[:200])
+            # A pip error is not fatal -- the daemon may not need the upgrade --
+            # but it is said, with pip's own output (2>&1 sends it to stdout).
+            logger.warning(
+                "SpoolBuddy %s: pip install exited %s (continuing): %s", device_id, rc, (pip_output or stderr)[-500:]
+            )
+            deps_note = f" (installing dependencies failed, exit {rc}: see the Bambuddy log)"
 
         # Step 5: Restart daemon
         await _update_progress("updating", "Restarting daemon...")
@@ -350,7 +369,7 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
             logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
 
         logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
-        await _update_progress("complete", f"Updated to {branch}")
+        await _update_progress("complete", f"Updated to {branch}{deps_note}")
 
     except Exception:
         logger.exception("SpoolBuddy %s: SSH update failed", device_id)

+ 108 - 0
backend/tests/integration/test_spoolbuddy_write_tag_spoolman.py

@@ -0,0 +1,108 @@
+"""Writing an NFC sticker for a Spoolman spool (SM-C6, SM-C5).
+
+SM-C6: the "color_name not set" warning checked the Spoolman filament field,
+which Spoolman never fills -- Bambuddy keeps the name in the spool's extra
+-- so every write warned. A spool without a stored name gets its subtype
+encoded, not an empty name.
+
+SM-C5: a Bambu spool created from the AMS is linked by its tray UUID in
+``extra.tag``. Writing a sticker replaced it, so the next AMS sync no longer
+found the spool and created a duplicate.
+"""
+
+import json
+from datetime import datetime, timezone
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+from backend.tests._fixtures.spoolman_tags import BASE_URL, FakeSpoolman
+from backend.tests.integration.test_spoolman_native_tags_3168 import TRAY_UUID, serving
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+WRITE = "/api/v1/spoolbuddy/nfc/write-tag"
+
+
+@pytest.fixture
+async def ready(db_session: AsyncSession):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value=BASE_URL))
+    db_session.add(
+        SpoolBuddyDevice(
+            device_id="sb-sticker",
+            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),
+        )
+    )
+    await db_session.commit()
+
+
+async def _write(async_client, fake: FakeSpoolman, spool_id: int):
+    with serving(fake):
+        return await async_client.post(WRITE, json={"device_id": "sb-sticker", "spool_id": spool_id})
+
+
+def _colour_warnings(response) -> list[str]:
+    return [w for w in response.json().get("warnings", []) if "color" in w.lower()]
+
+
+async def test_a_stored_colour_name_raises_no_warning(async_client, ready):
+    fake = FakeSpoolman()
+    fake.add_spool(7)["extra"]["bambu_color_name"] = json.dumps("Jade White")
+
+    response = await _write(async_client, fake, 7)
+
+    assert response.status_code == 200, response.text
+    assert _colour_warnings(response) == []
+
+
+async def test_a_missing_colour_name_says_the_subtype_is_encoded(async_client, ready):
+    fake = FakeSpoolman()
+    fake.add_spool(7)
+
+    response = await _write(async_client, fake, 7)
+
+    assert response.status_code == 200, response.text
+    [warning] = _colour_warnings(response)
+    assert "subtype" in warning
+
+
+async def test_a_spool_linked_by_its_ams_tray_uuid_is_not_overwritten(async_client, ready):
+    fake = FakeSpoolman()
+    fake.add_spool(7, extra_tag=TRAY_UUID)
+
+    response = await _write(async_client, fake, 7)
+
+    assert response.status_code == 409
+    assert "AMS" in response.json()["detail"]
+
+
+async def test_a_tray_uuid_spoolman_also_keeps_natively_is_no_reason_to_refuse(async_client, ready):
+    """Spoolman 0.27+ keeps the tray UUID as a native tag too, and the AMS
+    sync finds the spool by that first: the sticker cannot break the link."""
+    fake = FakeSpoolman()
+    fake.add_spool(7, extra_tag=TRAY_UUID, tags=[TRAY_UUID])
+
+    response = await _write(async_client, fake, 7)
+
+    assert response.status_code == 200, response.text
+
+
+async def test_a_chip_uid_in_extra_tag_is_no_reason_to_refuse(async_client, ready):
+    """The tray UUID only native, extra.tag holding a chip UID: nothing a
+    sticker overwrites links the spool to the AMS."""
+    fake = FakeSpoolman()
+    fake.add_spool(7, extra_tag="D3E68F32", tags=[TRAY_UUID])
+
+    response = await _write(async_client, fake, 7)
+
+    assert response.status_code == 200, response.text

+ 59 - 0
backend/tests/unit/test_spoolbuddy_ssh.py

@@ -717,3 +717,62 @@ async def test_perform_ssh_update_passes_str_not_bytes_to_import_known_hosts(tmp
     assert captured_args, "import_known_hosts was never called"
     for arg in captured_args:
         assert isinstance(arg, str), f"asyncssh.import_known_hosts must receive str, got {type(arg).__name__}: {arg!r}"
+
+
+async def _update_with_pip_result(tmp_path, pip_result):
+    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
+    ssh_dir.mkdir(parents=True)
+    (ssh_dir / "id_ed25519").write_text("PRIVATE")
+    (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
+    calls: list[tuple[str, float]] = []
+
+    async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
+        calls.append((cmd, timeout))
+        if "pip" in cmd:
+            return pip_result
+        return 0, "ok", "", "ssh-ed25519 AAAA fakehostkey"
+
+    _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
+    with (
+        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+        patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
+        patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="dev"),
+        patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
+        patch("backend.app.core.database.async_session", return_value=mock_ctx),
+        patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
+    ):
+        mock_settings.base_dir = tmp_path
+        await perform_ssh_update("sb-test", "10.0.0.1")
+    last = mock_ws.broadcast.call_args_list[-1].args[0]
+    return calls, last
+
+
+@pytest.mark.asyncio
+async def test_a_pip_timeout_stops_before_the_daemon_is_restarted(tmp_path):
+    """TO-C12: pip may still be running on the Pi; restarting the daemon over a
+    half-installed venv, and reporting success, hid that."""
+    calls, last = await _update_with_pip_result(tmp_path, (-1, "", "SSH command timed out", None))
+
+    assert last["update_status"] == "error"
+    assert "timed out" in last["update_message"]
+    assert not any("spoolbuddy.service" in cmd for cmd, _ in calls)
+
+
+@pytest.mark.asyncio
+async def test_a_failed_pip_install_completes_but_says_so(tmp_path):
+    """A pip error still restarts (the daemon may not need the upgrade), but
+    the final status names it, with pip's own output in the log."""
+    calls, last = await _update_with_pip_result(
+        tmp_path, (1, "ERROR: No matching distribution found for gpiod", "", None)
+    )
+
+    assert last["update_status"] == "complete"
+    assert "dependencies" in last["update_message"]
+    assert any("spoolbuddy.service" in cmd for cmd, _ in calls)
+
+
+@pytest.mark.asyncio
+async def test_pip_gets_time_for_a_slow_pi(tmp_path):
+    calls, _ = await _update_with_pip_result(tmp_path, (0, "ok", "", None))
+
+    assert next(timeout for cmd, timeout in calls if "pip" in cmd) >= 300

+ 47 - 0
frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx

@@ -286,6 +286,36 @@ describe('SpoolBuddyWriteTagPage', () => {
     mockOutletContext.sbState.deviceOnline = false; // reset
   });
 
+  it('says why a write for a spool linked to its AMS RFID tag is refused (SM-C5)', async () => {
+    const spool = {
+      id: 98,
+      material: 'PLA',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: null,
+      tray_uuid: null,
+      archived_at: null,
+      rgba: 'FF0000FF',
+    };
+    vi.mocked(mockedApi.getSpools).mockResolvedValue([spool as never]);
+    vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([
+      { device_id: 'sb-test', hostname: 'sb-test.local' } as never,
+    ]);
+    vi.mocked(mockedSpoolbuddyApi.writeTag).mockRejectedValueOnce(
+      Object.assign(new Error('linked to its Bambu RFID tag'), { status: 409 }),
+    );
+
+    mockOutletContext.sbState.deviceOnline = true;
+    renderPage();
+    await waitFor(() => expect(screen.getByText('PLA')).toBeDefined());
+    fireEvent.click(screen.getByText('PLA'));
+    await waitFor(() => expect((screen.getByText('Write Tag') as HTMLButtonElement).disabled).toBe(false));
+    fireEvent.click(screen.getByText('Write Tag'));
+
+    await waitFor(() => expect(screen.getByText('spoolbuddy.writeTag.linkedToAms')).toBeDefined());
+    mockOutletContext.sbState.deviceOnline = false; // reset
+  });
+
   it('reads from internal inventory when Spoolman mode is OFF (#1439 parity baseline)', async () => {
     vi.mocked(mockedApi.getSpoolmanSettings).mockResolvedValue({
       spoolman_enabled: 'false',
@@ -365,6 +395,23 @@ describe('SpoolBuddyWriteTagPage', () => {
     mockOutletContext.sbState.deviceOnline = false;
   });
 
+  it('leaves a spool linked by its AMS tray UUID out of the untagged list (SM-C5)', async () => {
+    // A Bambu spool created from the AMS is linked by its tray UUID; listed as
+    // untagged, a sticker written to it replaced that link in Spoolman.
+    const spools = [
+      { id: 50, material: 'PLA', label_weight: 1000, weight_used: 0, tag_uid: null, tray_uuid: null, archived_at: null, rgba: 'FF0000FF' },
+      { id: 51, material: 'PLA', label_weight: 1000, weight_used: 0, tag_uid: null, tray_uuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3', archived_at: null, rgba: 'FF0000FF' },
+    ];
+    vi.mocked(mockedApi.getSpools).mockResolvedValue(spools as never);
+    mockOutletContext.sbState.deviceOnline = true;
+    renderPage();
+
+    await waitFor(() => expect(screen.getByText('#50')).toBeDefined());
+    expect(screen.queryByText('#51')).toBeNull();
+
+    mockOutletContext.sbState.deviceOnline = false;
+  });
+
   describe('per-spool tare in Spoolman mode (#2908)', () => {
     beforeEach(() => {
       vi.mocked(mockedApi.getSpoolmanSettings).mockResolvedValue({

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

@@ -7527,6 +7527,7 @@ export default {
       linkFailedSpoolNotFound: 'Der Tag wurde beschrieben, aber die Spule existiert nicht mehr',
       spoolmanUnavailable: 'Spoolman ist nicht verfügbar. Prüfe die URL in den Spoolman-Einstellungen.',
       queueFailed: 'Schreibbefehl konnte nicht eingereiht werden',
+      linkedToAms: 'Diese Spule ist über das AMS mit ihrem Bambu-RFID-Tag verknüpft. Ein Sticker würde diese Verknüpfung in Spoolman ersetzen.',
       tryAgain: 'Erneut versuchen',
       cancel: 'Abbrechen',
       replaceWarning: 'Alter Tag wird getrennt. Neuer Tag ersetzt ihn.',

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

@@ -7580,6 +7580,7 @@ export default {
       linkFailedSpoolNotFound: 'The tag was written, but the spool no longer exists',
       spoolmanUnavailable: 'Spoolman is unavailable. Check its URL in the Spoolman settings.',
       queueFailed: 'Failed to queue write command',
+      linkedToAms: 'This spool is linked to its Bambu RFID tag through the AMS. A sticker would replace that link in Spoolman.',
       tryAgain: 'Try Again',
       cancel: 'Cancel',
       replaceWarning: 'Old tag will be unlinked. New tag will replace it.',

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

@@ -7535,6 +7535,7 @@ export default {
       linkFailedSpoolNotFound: 'La etiqueta se escribió, pero la bobina ya no existe',
       spoolmanUnavailable: 'Spoolman no está disponible. Comprueba su URL en los ajustes de Spoolman.',
       queueFailed: 'Error al encolar el comando de escritura',
+      linkedToAms: 'Esta bobina está vinculada a su etiqueta RFID de Bambu a través del AMS. Una pegatina reemplazaría ese vínculo en Spoolman.',
       tryAgain: 'Inténtelo de nuevo',
       cancel: 'Cancelar',
       replaceWarning: 'La etiqueta antigua se desvinculará. La nueva etiqueta la reemplazará.',

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

@@ -7515,6 +7515,7 @@ export default {
       linkFailedSpoolNotFound: 'L\'étiquette a été écrite, mais la bobine n\'existe plus',
       spoolmanUnavailable: 'Spoolman n\'est pas disponible. Vérifiez son URL dans les paramètres Spoolman.',
       queueFailed: 'Impossible de mettre en file la commande d\'écriture',
+      linkedToAms: "Cette bobine est liée à son tag RFID Bambu via l'AMS. Un autocollant remplacerait ce lien dans Spoolman.",
       tryAgain: 'Réessayer',
       cancel: 'Annuler',
       replaceWarning: 'L\'ancien tag sera dissocié. Le nouveau tag le remplacera.',

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

@@ -7514,6 +7514,7 @@ export default {
       linkFailedSpoolNotFound: 'Il tag è stato scritto, ma la bobina non esiste più',
       spoolmanUnavailable: 'Spoolman non è disponibile. Controlla l\'URL nelle impostazioni di Spoolman.',
       queueFailed: 'Impossibile accodare il comando di scrittura',
+      linkedToAms: "Questa bobina è collegata al suo tag RFID Bambu tramite l'AMS. Un adesivo sostituirebbe quel collegamento in Spoolman.",
       tryAgain: 'Riprova',
       cancel: 'Annulla',
       replaceWarning: 'Il vecchio tag verrà scollegato. Il nuovo tag lo sostituirà.',

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

@@ -7528,6 +7528,7 @@ export default {
       linkFailedSpoolNotFound: 'タグは書き込まれましたが、スプールはもう存在しません',
       spoolmanUnavailable: 'Spoolman が利用できません。Spoolman 設定の URL を確認してください。',
       queueFailed: '書込みコマンドのキューに失敗しました',
+      linkedToAms: 'このスプールは AMS を通じて Bambu の RFID タグに紐付けられています。ステッカーを書き込むと Spoolman 上のその紐付けが置き換えられます。',
       tryAgain: '再試行',
       cancel: 'キャンセル',
       replaceWarning: '古いタグのリンクが解除され、新しいタグに置き換わります。',

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

@@ -7168,6 +7168,7 @@ export default {
       linkFailedSpoolNotFound: '태그는 기록되었지만 스풀이 더 이상 존재하지 않습니다',
       spoolmanUnavailable: 'Spoolman을 사용할 수 없습니다. Spoolman 설정에서 URL을 확인하세요.',
       queueFailed: '쓰기 명령 대기 실패',
+      linkedToAms: '이 스풀은 AMS를 통해 Bambu RFID 태그에 연결되어 있습니다. 스티커를 쓰면 Spoolman에서 그 연결이 대체됩니다.',
       tryAgain: '다시 시도',
       cancel: '취소',
       replaceWarning: '이전 태그가 연결 해제됩니다. 새 태그가 교체합니다.',

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

@@ -7578,6 +7578,7 @@ export default {
       linkFailedSpoolNotFound: 'De tag is geschreven, maar de spoel bestaat niet meer',
       spoolmanUnavailable: 'Spoolman is niet beschikbaar. Controleer de URL in de Spoolman-instellingen.',
       queueFailed: 'Schrijfopdracht in wachtrij plaatsen mislukt',
+      linkedToAms: 'Deze spoel is via de AMS gekoppeld aan zijn Bambu-RFID-tag. Een sticker zou die koppeling in Spoolman vervangen.',
       tryAgain: 'Opnieuw proberen',
       cancel: 'Annuleren',
       replaceWarning: 'Oude tag wordt ontkoppeld. Nieuwe tag vervangt deze.',

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

@@ -7514,6 +7514,7 @@ export default {
       linkFailedSpoolNotFound: 'A etiqueta foi gravada, mas o carretel não existe mais',
       spoolmanUnavailable: 'O Spoolman não está disponível. Verifique a URL nas configurações do Spoolman.',
       queueFailed: 'Falha ao enfileirar comando de gravação',
+      linkedToAms: 'Este carretel está vinculado à sua tag RFID Bambu pelo AMS. Um adesivo substituiria esse vínculo no Spoolman.',
       tryAgain: 'Tentar novamente',
       cancel: 'Cancelar',
       replaceWarning: 'O tag antigo será desvinculado. O novo tag o substituirá.',

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

@@ -7156,6 +7156,7 @@ export default {
       linkFailedSpoolNotFound: "Метка записана, но катушка больше не существует",
       spoolmanUnavailable: "Spoolman недоступен. Проверьте URL в настройках Spoolman.",
       queueFailed: "Не удалось поставить команду записи в очередь",
+      linkedToAms: 'Эта катушка связана со своей RFID-меткой Bambu через AMS. Наклейка заменит эту связь в Spoolman.',
       tryAgain: "Повторить",
       cancel: "Отмена",
       replaceWarning: "Старая метка будет отвязана и заменена новой.",

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

@@ -7576,6 +7576,7 @@ errors: {
       linkFailedSpoolNotFound: 'Taggen skrevs, men spolen finns inte längre',
       spoolmanUnavailable: 'Spoolman är inte tillgängligt. Kontrollera URL:en i Spoolman-inställningarna.',
       queueFailed: 'Kunde inte köa skrivkommando',
+      linkedToAms: 'Den här spolen är länkad till sin Bambu-RFID-tagg via AMS. En klistermärkesetikett skulle ersätta den länken i Spoolman.',
       tryAgain: 'Försök igen',
       cancel: 'Avbryt',
       replaceWarning: 'Gamla taggen kommer att avlänkas. Ny tagg kommer att ersätta den.',

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

@@ -7465,6 +7465,7 @@ export default {
       linkFailedSpoolNotFound: 'Etiket yazıldı, ancak makara artık mevcut değil',
       spoolmanUnavailable: 'Spoolman kullanılamıyor. Spoolman ayarlarındaki URL\'yi kontrol edin.',
       queueFailed: 'Yazma komutu kuyruğa alınamadı',
+      linkedToAms: "Bu makara, AMS üzerinden Bambu RFID etiketine bağlı. Bir etiket yazmak Spoolman'deki bu bağlantının yerini alır.",
       tryAgain: 'Tekrar Dene',
       cancel: 'İptal',
       replaceWarning: 'Eski etiket bağlantısı kaldırılacak. Yeni etiket yerini alacak.',

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

@@ -7568,6 +7568,7 @@ export default {
       linkFailedSpoolNotFound: "Мітку записано, але котушки більше не існує",
       spoolmanUnavailable: "Spoolman недоступний. Перевірте URL у налаштуваннях Spoolman.",
       queueFailed: "Не вдалося поставити команду запису в чергу",
+      linkedToAms: "Ця котушка пов'язана зі своєю RFID-міткою Bambu через AMS. Наліпка замінила б цей зв'язок у Spoolman.",
       tryAgain: "Спробуйте знову",
       cancel: "Скасувати",
       replaceWarning: "Старий тег буде від’єднано від котушки, а замість нього буде прив’язано новий.",

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

@@ -7512,6 +7512,7 @@ export default {
       linkFailedSpoolNotFound: '标签已写入,但料卷已不存在',
       spoolmanUnavailable: 'Spoolman 不可用。请检查 Spoolman 设置中的 URL。',
       queueFailed: '排队写入命令失败',
+      linkedToAms: '此料卷已通过 AMS 关联到其 Bambu RFID 标签。写入贴纸会替换 Spoolman 中的这一关联。',
       tryAgain: '重试',
       cancel: '取消',
       replaceWarning: '旧标签将被取消链接。新标签将替换它。',

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

@@ -7512,6 +7512,7 @@ export default {
       linkFailedSpoolNotFound: '標籤已寫入,但料卷已不存在',
       spoolmanUnavailable: 'Spoolman 無法使用。請檢查 Spoolman 設定中的 URL。',
       queueFailed: '佇列寫入命令失敗',
+      linkedToAms: '此料捲已透過 AMS 連結到其 Bambu RFID 標籤。寫入貼紙會取代 Spoolman 中的這項連結。',
       tryAgain: '重試',
       cancel: '取消',
       replaceWarning: '舊標籤將被取消連結。新標籤將替換它。',

+ 10 - 3
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -124,7 +124,10 @@ export function SpoolBuddyWriteTagPage() {
     void colorCatalogVersion;
     let list: InventorySpool[];
     if (activeTab === 'existing') {
-      list = spools.filter(s => !s.tag_uid && !s.archived_at);
+      // Not a spool linked by its AMS tray UUID either: that is its tag, and
+      // in Spoolman a sticker would replace it (SM-C5). The two tabs are
+      // each other's complement.
+      list = spools.filter(s => !s.tag_uid && !s.tray_uuid && !s.archived_at);
     } else if (activeTab === 'replace') {
       list = spools.filter(s => (s.tag_uid || s.tray_uuid) && !s.archived_at);
     } else {
@@ -245,10 +248,14 @@ export function SpoolBuddyWriteTagPage() {
       }
     } catch (err) {
       setWriteStatus('error');
+      const status = (err as { status?: number } | null)?.status;
       setWriteMessage(
-        (err as { status?: number } | null)?.status === 503
+        status === 503
           ? t('spoolbuddy.writeTag.spoolmanUnavailable')
-          : t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'),
+          : status === 409
+            // A Bambu spool linked by its tray ID in Spoolman (SM-C5).
+            ? t('spoolbuddy.writeTag.linkedToAms')
+            : t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'),
       );
     }
   };