Browse Source

[Fix]: Non-Bambu Lab Spools can now be fully linked/unlinked to Spoolman (#653)

* Expanded link logic to accept a generic spool tag (spool_tag, tray_uuid, or tag_uid) and validate 16/32-char hex values, including rejection of all-zero tags. Added a new unlink endpoint that clears Spoolman extra.tag for a given spool ID.

* Updated the link validation expectation to match the new 16-or-32-hex rule. Added an integration test for the new unlink endpoint to verify extra.tag is cleared and success is returned.

* Updated the link API call to send spool_tag instead of tray_uuid. Added a new unlinkSpool API helper that calls the new backend unlink route.

* Switched modal data source from inventory spools to Spoolman unlinked spools so the list matches the backend response you shared. Updated the link action to call Spoolman link directly and use trayUuid or tagUid as the slot tag.

* Refactored Spoolman card behavior so actions render correctly across linked/unlinked cases, and added support for an Unlink from Spoolman button under Open in Spoolman. Final UI rule now hides Unlink for Bambu Lab filament and shows it only for non-Bambu linked spools.

* Added fallback slot-tag generation so untagged slots can still be linked/unlinked reliably and linked spool lookups work consistently across AMS, AMS-HT, and external trays. Wired a new unlink mutation and passed per-slot unlink callbacks into the hover card, with query invalidation for linked/unlinked spool caches after unlink.

* Added/updated the noTrayUuid message to reflect that either tray UUID or tag UID is required when no slot tag is available. This keeps error text aligned with the new tag fallback and modal behavior.

* Added noTrayUuid message in the other languages. The translation was done using Google Translate.

* Updated LinkSpoolModal tests to use Spoolman API instead of inventory API, with new UnlinkedSpool data structure and Select Spool UI text.

* Adds hashSerialToHex32, uses new function in getFallbackSpoolTag and updates all uses. This ensures we're generating a fallback spool tag using the printers serial number rather than the database ID for consistency.

* Removes 'import json' from unlink_spool and moves to the top of the file.

* Removes hasUnlinkedSpools and related references since it's no longer used

* Removes noTrayUuid from translations and references in components.

* Adds confirmation dialog before fully unlinking spool from Spoolman

* Adds unlinkConfrimTitle and unlinkConfirmMessage. All languages other than English were done using Google Translate

* Fixes unlink toast to use dedicated unlinkSuccess and unlinkFailed keys.

* Changes tag priority on spoolTag, fixes type error by adding non-null assertion to spoolTag

* Switches around tag_uid and tray_uuid on const trayTag

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
Dakota G 2 months ago
parent
commit
971984f91d

+ 54 - 12
backend/app/api/routes/spoolman.py

@@ -1,6 +1,7 @@
 """Spoolman integration API routes."""
 
 import logging
+import json
 
 from fastapi import APIRouter, Depends, HTTPException
 from pydantic import BaseModel
@@ -663,9 +664,11 @@ async def get_linked_spools(
 
 
 class LinkSpoolRequest(BaseModel):
-    """Request to link a Spoolman spool to an AMS tray."""
+    """Request to link a Spoolman spool to an AMS tag (tray_uuid or tag_uid)."""
 
-    tray_uuid: str
+    spool_tag: str | None = None
+    tray_uuid: str | None = None
+    tag_uid: str | None = None
 
 
 @router.post("/spools/{spool_id}/link")
@@ -675,7 +678,7 @@ async def link_spool(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
-    """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
+    """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
     sm = await get_spoolman_settings(db)
     enabled, url = sm["enabled"], sm["url"]
     if not enabled:
@@ -691,14 +694,19 @@ async def link_spool(
     if not await client.health_check():
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
-    # Validate tray_uuid format (32 hex characters)
-    tray_uuid = request.tray_uuid.strip()
-    if len(tray_uuid) != 32:
-        raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)")
+    # Resolve and validate spool tag (supports tray_uuid=32 hex and tag_uid=16 hex)
+    spool_tag = (request.spool_tag or request.tray_uuid or request.tag_uid or "").strip()
+    if not spool_tag:
+        raise HTTPException(status_code=400, detail="Missing spool tag (tray_uuid or tag_uid)")
+    if len(spool_tag) not in (16, 32):
+        raise HTTPException(status_code=400, detail="Invalid spool tag format (must be 16 or 32 hex characters)")
     try:
-        int(tray_uuid, 16)
+        int(spool_tag, 16)
     except ValueError:
-        raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)")
+        raise HTTPException(status_code=400, detail="Invalid spool tag format (must be hex)")
+
+    if set(spool_tag) == {"0"}:
+        raise HTTPException(status_code=400, detail="Invalid spool tag format (all-zero tag is not linkable)")
 
     # Update spool with tag
     # Note: Spoolman extra field values must be valid JSON, so we encode the string
@@ -706,11 +714,45 @@ async def link_spool(
 
     result = await client.update_spool(
         spool_id=spool_id,
-        extra={"tag": json.dumps(tray_uuid)},
+        extra={"tag": json.dumps(spool_tag)},
+    )
+
+    if result:
+        logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
+        return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
+    else:
+        raise HTTPException(status_code=500, detail="Failed to update spool")
+
+
+@router.post("/spools/{spool_id}/unlink")
+async def unlink_spool(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+):
+    """Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag."""
+    sm = await get_spoolman_settings(db)
+    enabled, url = sm["enabled"], sm["url"]
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    result = await client.update_spool(
+        spool_id=spool_id,
+        extra={"tag": json.dumps("")},
     )
 
     if result:
-        logger.info("Linked Spoolman spool %s to tray_uuid %s", spool_id, tray_uuid)
-        return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
+        logger.info("Unlinked Spoolman spool %s", spool_id)
+        return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
     else:
         raise HTTPException(status_code=500, detail="Failed to update spool")

+ 18 - 1
backend/tests/integration/test_spoolman_api.py

@@ -430,7 +430,7 @@ class TestSpoolmanAPI:
             json={"tray_uuid": "ABC123"},  # Too short
         )
         assert response.status_code == 400
-        assert "32 hex characters" in response.json()["detail"]
+        assert "16 or 32 hex characters" in response.json()["detail"]
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -465,6 +465,23 @@ class TestSpoolmanAPI:
         # Verify update_spool was called
         mock_spoolman_client.update_spool.assert_called_once()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unlink_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify successfully unlinking a spool clears extra.tag."""
+        mock_spoolman_client.update_spool = AsyncMock(return_value={"id": 1, "extra": {"tag": '""'}})
+
+        response = await async_client.post("/api/v1/spoolman/spools/1/unlink")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+        assert "unlinked" in data["message"].lower()
+
+        mock_spoolman_client.update_spool.assert_called_once_with(
+            spool_id=1,
+            extra={"tag": '""'},
+        )
+
     # =========================================================================
     # Sync Tests
     # =========================================================================

+ 30 - 60
frontend/src/__tests__/components/LinkSpoolModal.test.tsx

@@ -16,8 +16,8 @@ import { LinkSpoolModal } from '../../components/LinkSpoolModal';
 // Mock the API client
 vi.mock('../../api/client', () => ({
   api: {
-    getSpools: vi.fn(),
-    linkTagToSpool: vi.fn(),
+    getUnlinkedSpools: vi.fn(),
+    linkSpool: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
   },
@@ -50,46 +50,24 @@ describe('LinkSpoolModal', () => {
   const mockSpools = [
     {
       id: 1,
-      material: 'PLA',
-      brand: 'Generic',
-      subtype: '',
-      color_name: 'Red',
-      rgba: 'FF0000FF',
-      label_weight: 1000,
-      weight_used: 200,
-      tag_uid: null,
-      tray_uuid: null,
+      filament_name: 'Generic PLA Red',
+      filament_material: 'PLA',
+      filament_color_hex: 'FF0000',
+      remaining_weight: 800,
     },
     {
       id: 2,
-      material: 'PETG',
-      brand: 'Bambu',
-      subtype: 'Basic',
-      color_name: 'Blue',
-      rgba: '0000FFFF',
-      label_weight: 1000,
-      weight_used: 500,
-      tag_uid: null,
-      tray_uuid: null,
-    },
-    {
-      id: 3,
-      material: 'ABS',
-      brand: 'Brand',
-      subtype: '',
-      color_name: 'White',
-      rgba: 'FFFFFFFF',
-      label_weight: 1000,
-      weight_used: 0,
-      tag_uid: 'EXISTING_TAG',
-      tray_uuid: 'EXISTING_UUID',
+      filament_name: 'Bambu PETG Blue',
+      filament_material: 'PETG',
+      filament_color_hex: '0000FF',
+      remaining_weight: 500,
     },
   ];
 
   beforeEach(() => {
     vi.clearAllMocks();
-    vi.mocked(api.getSpools).mockResolvedValue(mockSpools);
-    vi.mocked(api.linkTagToSpool).mockResolvedValue({});
+    vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockSpools);
+    vi.mocked(api.linkSpool).mockResolvedValue({});
   });
 
   describe('rendering', () => {
@@ -97,7 +75,7 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();
       });
     });
 
@@ -111,7 +89,7 @@ describe('LinkSpoolModal', () => {
     });
 
     it('shows loading state while fetching spools', async () => {
-      vi.mocked(api.getSpools).mockImplementation(() => new Promise(() => {}));
+      vi.mocked(api.getUnlinkedSpools).mockImplementation(() => new Promise(() => {}));
 
       render(<LinkSpoolModal {...defaultProps} />);
 
@@ -120,42 +98,34 @@ describe('LinkSpoolModal', () => {
       });
     });
 
-    it('displays untagged spools only', async () => {
+    it('displays unlinked spools from Spoolman', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        // Spools 1 and 2 have no tag_uid/tray_uuid — should be shown
-        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
-        expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
+        // Should show spools from getUnlinkedSpools
+        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
+        expect(screen.getByText(/Bambu PETG Blue/)).toBeInTheDocument();
       });
-
-      // Spool 3 has tag_uid — should be filtered out
-      expect(screen.queryByText(/Brand ABS/)).not.toBeInTheDocument();
     });
 
     it('does not render when isOpen is false', () => {
       render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
-      expect(screen.queryByRole('heading', { name: /link to spool/i })).not.toBeInTheDocument();
+      expect(screen.queryByRole('heading', { name: /select spool/i })).not.toBeInTheDocument();
     });
   });
 
   describe('linking', () => {
-    it('calls linkTagToSpool on spool click', async () => {
+    it('calls linkSpool on spool click', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
       });
 
-      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
+      fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
 
       await waitFor(() => {
-        expect(api.linkTagToSpool).toHaveBeenCalledWith(1, {
-          tag_uid: 'ABCD1234',
-          tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
-          tag_type: 'bambulab',
-          data_origin: 'nfc_link',
-        });
+        expect(api.linkSpool).toHaveBeenCalledWith(1, 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4');
       });
     });
 
@@ -163,10 +133,10 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
       });
 
-      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
+      fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
 
       await waitFor(() => {
         expect(mockShowToast).toHaveBeenCalled();
@@ -175,15 +145,15 @@ describe('LinkSpoolModal', () => {
     });
 
     it('shows error toast on failure', async () => {
-      vi.mocked(api.linkTagToSpool).mockRejectedValue(new Error('Link failed'));
+      vi.mocked(api.linkSpool).mockRejectedValue(new Error('Link failed'));
 
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
       });
 
-      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
+      fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
 
       await waitFor(() => {
         expect(mockShowToast).toHaveBeenCalledWith(
@@ -199,7 +169,7 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();
       });
 
       const backdrop = document.querySelector('.bg-black\\/60');
@@ -213,7 +183,7 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
-        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();
       });
 
       const closeButtons = screen.getAllByRole('button');

+ 6 - 2
frontend/src/api/client.ts

@@ -3527,10 +3527,14 @@ export const api = {
     request<UnlinkedSpool[]>('/spoolman/spools/unlinked'),
   getLinkedSpools: () =>
     request<LinkedSpoolsMap>('/spoolman/spools/linked'),
-  linkSpool: (spoolId: number, trayUuid: string) =>
+  linkSpool: (spoolId: number, spoolTag: string) =>
     request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/link`, {
       method: 'POST',
-      body: JSON.stringify({ tray_uuid: trayUuid }),
+      body: JSON.stringify({ spool_tag: spoolTag }),
+    }),
+  unlinkSpool: (spoolId: number) =>
+    request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/unlink`, {
+      method: 'POST',
     }),
   getSpoolmanSettings: () =>
     request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman'),

+ 97 - 37
frontend/src/components/FilamentHoverCard.tsx

@@ -11,13 +11,14 @@ interface FilamentData {
   kFactor: string;
   fillLevel: number | null; // null = unknown
   trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
+  tagUid?: string | null; // Generic NFC tag UID fallback for linking
   fillSource?: 'ams' | 'spoolman' | 'inventory'; // Source of fill level data
 }
 
 interface SpoolmanConfig {
   enabled: boolean;
-  onLinkSpool?: (trayUuid: string) => void;
-  hasUnlinkedSpools?: boolean; // Whether there are spools available to link
+  onLinkSpool?: () => void;
+  onUnlinkSpool?: () => void;
   linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked
   spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
 }
@@ -52,6 +53,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [copied, setCopied] = useState(false);
+  const [showUnlinkConfirm, setShowUnlinkConfirm] = useState(false);
   const triggerRef = useRef<HTMLDivElement>(null);
   const cardRef = useRef<HTMLDivElement>(null);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -258,63 +260,82 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
               </div>
 
               {/* Spoolman section - only show if enabled */}
-              {spoolman?.enabled && data.trayUuid && (
+              {spoolman?.enabled && (
                 <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
                   {/* Tray UUID with copy button */}
                   <div className="flex items-center justify-between">
                     <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
                       {t('spoolman.spoolId')}
                     </span>
-                    <button
-                      onClick={(e) => {
-                        e.stopPropagation();
-                        handleCopyUuid();
-                      }}
-                      className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
-                      title="Copy spool UUID"
-                    >
-                      <span className="font-mono text-[10px] truncate max-w-[80px]">
-                        {data.trayUuid.slice(0, 8)}...
-                      </span>
-                      {copied ? (
-                        <Check className="w-3 h-3 text-bambu-green" />
-                      ) : (
-                        <Copy className="w-3 h-3" />
-                      )}
-                    </button>
+                    {data.trayUuid ? (
+                      <button
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          handleCopyUuid();
+                        }}
+                        className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
+                        title="Copy spool UUID"
+                      >
+                        <span className="font-mono text-[10px] truncate max-w-[80px]">
+                          {data.trayUuid.slice(0, 8)}...
+                        </span>
+                        {copied ? (
+                          <Check className="w-3 h-3 text-bambu-green" />
+                        ) : (
+                          <Copy className="w-3 h-3" />
+                        )}
+                      </button>
+                    ) : (
+                      <span className="text-[10px] text-bambu-gray">—</span>
+                    )}
                   </div>
 
                   {/* Open in Spoolman button (when already linked) */}
                   {spoolman.linkedSpoolId && spoolman.spoolmanUrl && (
-                    <a
-                      href={`${spoolman.spoolmanUrl.replace(/\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}
-                      target="_blank"
-                      rel="noopener noreferrer"
-                      onClick={(e) => e.stopPropagation()}
-                      className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
-                      title={t('spoolman.openInSpoolman')}
-                    >
-                      <ExternalLink className="w-3.5 h-3.5" />
-                      {t('spoolman.openInSpoolman')}
-                    </a>
+                    <>
+                      <a
+                        href={`${spoolman.spoolmanUrl.replace(/\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        onClick={(e) => e.stopPropagation()}
+                        className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
+                        title={t('spoolman.openInSpoolman')}
+                      >
+                        <ExternalLink className="w-3.5 h-3.5" />
+                        {t('spoolman.openInSpoolman')}
+                      </a>
+
+                      {spoolman.onUnlinkSpool && data.vendor !== 'Bambu Lab' && (
+                        <button
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            setShowUnlinkConfirm(true);
+                          }}
+                          className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400"
+                          title={t('spoolman.unlinkSpool')}
+                        >
+                          <Unlink className="w-3.5 h-3.5" />
+                          {t('spoolman.unlinkSpool')}
+                        </button>
+                      )}
+                    </>
                   )}
 
                   {/* Link Spool button (when not linked) */}
-                  {!spoolman.linkedSpoolId && spoolman.onLinkSpool && (
+                  {!spoolman.linkedSpoolId && (
                     <button
                       onClick={(e) => {
                         e.stopPropagation();
-                        if (spoolman.hasUnlinkedSpools !== false) {
-                          spoolman.onLinkSpool?.(data.trayUuid!);
+                        if (spoolman.onLinkSpool) {
+                          spoolman.onLinkSpool?.();
                         }
                       }}
-                      disabled={spoolman.hasUnlinkedSpools === false}
+                      disabled={!spoolman.onLinkSpool}
                       className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors ${
-                        spoolman.hasUnlinkedSpools === false
+                        !spoolman.onLinkSpool
                           ? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'
                           : 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'
                       }`}
-                      title={spoolman.hasUnlinkedSpools === false ? t('spoolman.noUnlinkedSpools') : t('spoolman.linkToSpoolman')}
                     >
                       <Link2 className="w-3.5 h-3.5" />
                       {t('spoolman.linkToSpoolman')}
@@ -399,6 +420,45 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
           />
         </div>
       )}
+
+      {/* Unlink Confirmation Dialog */}
+      {showUnlinkConfirm && (
+        <div className="fixed inset-0 z-[100] flex items-center justify-center" onClick={() => setShowUnlinkConfirm(false)}>
+          <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
+          <div
+            className="relative bg-bambu-dark-secondary rounded-lg shadow-xl w-full max-w-sm mx-4 border border-bambu-dark-tertiary"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className="p-4 space-y-4">
+              <div className="space-y-2">
+                <h3 className="text-base font-semibold text-white">
+                  {t('spoolman.unlinkConfirmTitle')}
+                </h3>
+                <p className="text-sm text-bambu-gray">
+                  {t('spoolman.unlinkConfirmMessage')}
+                </p>
+              </div>
+              <div className="flex gap-2">
+                <button
+                  onClick={() => setShowUnlinkConfirm(false)}
+                  className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-bambu-dark hover:bg-bambu-dark-tertiary text-white"
+                >
+                  {t('common.cancel')}
+                </button>
+                <button
+                  onClick={() => {
+                    spoolman?.onUnlinkSpool?.();
+                    setShowUnlinkConfirm(false);
+                  }}
+                  className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400"
+                >
+                  {t('spoolman.unlinkSpool')}
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 21 - 27
frontend/src/components/LinkSpoolModal.tsx

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Search, Link } from 'lucide-react';
 import { api } from '../api/client';
-import type { InventorySpool } from '../api/client';
+import type { UnlinkedSpool } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 
@@ -22,44 +22,39 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [search, setSearch] = useState('');
+  const spoolTag = tagUid || trayUuid;
 
   const { data: spools, isLoading } = useQuery({
-    queryKey: ['inventory-spools'],
-    queryFn: () => api.getSpools(false),
+    queryKey: ['unlinked-spools'],
+    queryFn: api.getUnlinkedSpools,
     enabled: isOpen,
   });
 
-  // Filter to untagged spools matching search
+  // Filter Spoolman unlinked spools matching search
   const filteredSpools = useMemo(() => {
     if (!spools) return [];
-    return spools.filter((s: InventorySpool) => {
-      if (s.tag_uid || s.tray_uuid) return false; // Already tagged
+    return spools.filter((s: UnlinkedSpool) => {
       if (!search) return true;
       const q = search.toLowerCase();
       return (
-        s.material.toLowerCase().includes(q) ||
-        (s.brand && s.brand.toLowerCase().includes(q)) ||
-        (s.color_name && s.color_name.toLowerCase().includes(q))
+        (s.filament_name && s.filament_name.toLowerCase().includes(q)) ||
+        (s.filament_material && s.filament_material.toLowerCase().includes(q)) ||
+        String(s.id).includes(q)
       );
     });
   }, [spools, search]);
 
   const linkMutation = useMutation({
     mutationFn: (spoolId: number) =>
-      api.linkTagToSpool(spoolId, {
-        tag_uid: tagUid || undefined,
-        tray_uuid: trayUuid || undefined,
-        tag_type: trayUuid ? 'bambulab' : 'generic',
-        data_origin: 'nfc_link',
-      }),
+      api.linkSpool(spoolId, spoolTag!),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
-      queryClient.invalidateQueries({ queryKey: ['inventory-assignments'] });
-      showToast(t('inventory.tagLinked'), 'success');
+      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
+      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
+      showToast(t('spoolman.linkSuccess'), 'success');
       onClose();
     },
     onError: (err: Error) => {
-      showToast(err.message || t('inventory.tagLinkFailed'), 'error');
+      showToast(err.message || t('spoolman.linkFailed'), 'error');
     },
   });
 
@@ -74,7 +69,7 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a
           <div>
             <h3 className="text-lg font-semibold text-white flex items-center gap-2">
               <Link className="w-5 h-5 text-bambu-green" />
-              {t('inventory.linkToSpool')}
+              {t('spoolman.selectSpool')}
             </h3>
             <p className="text-xs text-bambu-gray mt-1">
               AMS {amsId} T{trayId} &middot; Printer #{printerId}
@@ -115,28 +110,27 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a
               {t('inventory.noSpoolsMatch')}
             </p>
           ) : (
-            filteredSpools.map((spool: InventorySpool) => (
+            filteredSpools.map((spool: UnlinkedSpool) => (
               <button
                 key={spool.id}
                 onClick={() => linkMutation.mutate(spool.id)}
-                disabled={linkMutation.isPending}
+                disabled={linkMutation.isPending || !spoolTag}
                 className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-white/5 transition-colors text-left"
               >
                 <span
                   className="w-6 h-6 rounded-full border border-white/20 flex-shrink-0"
-                  style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
+                  style={{ backgroundColor: spool.filament_color_hex ? `#${spool.filament_color_hex}` : '#808080' }}
                 />
                 <div className="flex-1 min-w-0">
                   <div className="text-sm text-white font-medium truncate">
-                    {spool.brand ? `${spool.brand} ` : ''}{spool.material}
-                    {spool.subtype ? ` ${spool.subtype}` : ''}
+                    {spool.filament_name || t('spoolman.spoolId')}
                   </div>
                   <div className="text-xs text-bambu-gray truncate">
-                    {spool.color_name || 'No color'} &middot; #{spool.id}
+                    {spool.filament_material || 'Unknown'} &middot; #{spool.id}
                   </div>
                 </div>
                 <span className="text-xs text-bambu-gray">
-                  {Math.round(spool.label_weight - spool.weight_used)}g
+                  {spool.remaining_weight != null ? `${Math.round(spool.remaining_weight)}g` : '—'}
                 </span>
               </button>
             ))

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

@@ -2776,10 +2776,14 @@ export default {
     linkToSpoolman: 'Mit Spoolman verknüpfen',
     openInSpoolman: 'In Spoolman öffnen',
     unlinkSpool: 'Spule trennen',
+    unlinkConfirmTitle: 'Spule entkoppeln?',
+    unlinkConfirmMessage: 'Dadurch wird die Spule von Spoolman getrennt. Die Spulendaten in Spoolman bleiben unverändert.',
     selectSpool: 'Spule auswählen',
     noUnlinkedSpools: 'Keine nicht verknüpften Spulen verfügbar',
     linkSuccess: 'Spule erfolgreich mit Spoolman verknüpft',
     linkFailed: 'Verknüpfung mit Spoolman fehlgeschlagen',
+    unlinkSuccess: 'Spule erfolgreich von Spoolman getrennt',
+    unlinkFailed: 'Trennen der Spule von Spoolman fehlgeschlagen',
     spoolId: 'Spulen-ID',
     fillSourceLabel: '(Spoolman)',
     weight: 'Gewicht',

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

@@ -2776,10 +2776,14 @@ export default {
     linkToSpoolman: 'Link to Spoolman',
     openInSpoolman: 'Open in Spoolman',
     unlinkSpool: 'Unlink Spool',
+    unlinkConfirmTitle: 'Unlink Spool?',
+    unlinkConfirmMessage: 'This will disconnect the spool from Spoolman. The spool data in Spoolman will remain unchanged.',
     selectSpool: 'Select Spool',
     noUnlinkedSpools: 'No unlinked spools available',
     linkSuccess: 'Spool linked to Spoolman successfully',
     linkFailed: 'Failed to link spool',
+    unlinkSuccess: 'Spool unlinked from Spoolman successfully',
+    unlinkFailed: 'Failed to unlink spool',
     spoolId: 'Spool ID',
     fillSourceLabel: '(Spoolman)',
     weight: 'Weight',

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

@@ -2763,10 +2763,14 @@ export default {
     linkToSpoolman: 'Lier à Spoolman',
     openInSpoolman: 'Ouvrir Spoolman',
     unlinkSpool: 'Délier bobine',
+    unlinkConfirmTitle: 'Dissocier la bobine?',
+    unlinkConfirmMessage: 'Cette opération déconnectera la bobine de Spoolman. Les données de la bobine dans Spoolman resteront inchangées.',
     selectSpool: 'Choisir bobine',
     noUnlinkedSpools: 'Pas de bobine libre',
     linkSuccess: 'Lien réussi',
     linkFailed: 'Échec lien',
+    unlinkSuccess: 'Bobine dissociée avec succès',
+    unlinkFailed: 'Échec de la dissociation de la bobine',
     spoolId: 'ID Bobine',
     fillSourceLabel: '(Spoolman)',
     weight: 'Poids',

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

@@ -2762,10 +2762,14 @@ export default {
     linkToSpoolman: 'Collega a Spoolman',
     openInSpoolman: 'Apri in Spoolman',
     unlinkSpool: 'Scollega bobina',
+    unlinkConfirmTitle: 'Scollegare bobina?',
+    unlinkConfirmMessage: 'Questo disconnetterà lo spool da Spoolman. I dati dello spool in Spoolman rimarranno invariati.',
     selectSpool: 'Seleziona bobina',
     noUnlinkedSpools: 'Nessuna bobina scollegata disponibile',
     linkSuccess: 'Bobina collegata a Spoolman con successo',
     linkFailed: 'Collegamento bobina fallito',
+    unlinkSuccess: 'Bobina scollegata da Spoolman con successo',
+    unlinkFailed: 'Impossibile scollegare la bobina',
     spoolId: 'ID bobina',
     fillSourceLabel: '(Spoolman)',
     weight: 'Peso',

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

@@ -2776,10 +2776,14 @@ export default {
     linkToSpoolman: 'Spoolmanに連携',
     openInSpoolman: 'Spoolmanで開く',
     unlinkSpool: 'スプールのリンクを解除',
+    unlinkConfirmTitle: 'スプールのリンクを解除しますか?',
+    unlinkConfirmMessage: 'これにより、スプールがSpoolmanから切断されます。Spoolman内のスプールデータは変更されません。',
     selectSpool: 'スプールを選択',
     noUnlinkedSpools: 'Spoolmanに未連携のスプールが見つかりません。',
     linkSuccess: 'スプールをSpoolmanにリンクしました',
     linkFailed: 'スプールのリンクに失敗しました',
+    unlinkSuccess: 'スプールをSpoolmanから解除しました',
+    unlinkFailed: 'スプールのリンク解除に失敗しました',
     spoolId: 'スプールID',
     fillSourceLabel: '(Spoolman)',
     weight: '重量',

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

@@ -2762,10 +2762,14 @@ export default {
     linkToSpoolman: 'Vincular ao Spoolman',
     openInSpoolman: 'Abrir no Spoolman',
     unlinkSpool: 'Desvincular Carretel',
+    unlinkConfirmTitle: 'Desvincular carretel?',
+    unlinkConfirmMessage: 'Isso desconectará o carretel do Spoolman. Os dados do carretel no Spoolman permanecerão inalterados.',
     selectSpool: 'Selecionar Carretel',
     noUnlinkedSpools: 'Nenhum carretel desvinculado disponível',
     linkSuccess: 'Carretel vinculado ao Spoolman com sucesso',
     linkFailed: 'Falha ao vincular carretel',
+    unlinkSuccess: 'Carretel desvinculado do Spoolman com sucesso',
+    unlinkFailed: 'Falha ao desvincular carretel',
     spoolId: 'Carretel ID (Spool ID)',
     fillSourceLabel: '(Spoolman)',
     weight: 'Peso',

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

@@ -2762,10 +2762,14 @@ export default {
     linkToSpoolman: '链接到 Spoolman',
     openInSpoolman: '在 Spoolman 中打开',
     unlinkSpool: '取消链接耗材',
+    unlinkConfirmTitle: '解开线轴?',
+    unlinkConfirmMessage: '这将断开卷轴与 Spoolman 的连接。Spoolman 中的卷轴数据将保持不变。',
     selectSpool: '选择耗材',
     noUnlinkedSpools: '无未链接的耗材',
     linkSuccess: '耗材已成功链接到 Spoolman',
     linkFailed: '链接耗材失败',
+    unlinkSuccess: '已成功从 Spoolman 取消链接耗材',
+    unlinkFailed: '取消链接耗材失败',
     spoolId: '耗材 ID',
     fillSourceLabel: '(Spoolman)',
     weight: '重量',

+ 62 - 22
frontend/src/pages/PrintersPage.tsx

@@ -1039,6 +1039,27 @@ function isBambuLabSpool(tray: {
   return false;
 }
 
+function toFixedHex(value: number, width: number): string {
+  const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
+  return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
+}
+
+// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
+function hashSerialToHex32(serial: string): string {
+  const input = (serial || '').trim().toUpperCase();
+  let hash = 0x811c9dc5;
+  for (let i = 0; i < input.length; i++) {
+    hash ^= input.charCodeAt(i);
+    hash = Math.imul(hash, 0x01000193);
+  }
+  return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
+}
+
+function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
+  // 16-char stable hex tag for slots without RFID identifiers
+  return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
+}
+
 function CoverImage({ url, printName }: { url: string | null; printName?: string }) {
   const { t } = useTranslation();
   const [loaded, setLoaded] = useState(false);
@@ -1492,7 +1513,6 @@ function PrinterCard({
   cardSize = 2,
   amsThresholds,
   spoolmanEnabled = false,
-  hasUnlinkedSpools = false,
   linkedSpools,
   spoolmanUrl,
   onGetAssignment,
@@ -1832,6 +1852,18 @@ function PrinterCard({
     },
   });
 
+  const unlinkSpoolMutation = useMutation({
+    mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),
+    onSuccess: (result) => {
+      showToast(t('spoolman.unlinkSuccess') || result?.message, 'success');
+      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
+      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('spoolman.unlinkFailed'), 'error');
+    },
+  });
+
   // Smart plug control mutations
   const powerControlMutation = useMutation({
     mutationFn: (action: 'on' | 'off') =>
@@ -3034,7 +3066,7 @@ function PrinterCard({
                                 const slotPreset = slotPresets?.[globalTrayId];
 
                                 // Fill level fallback chain: Spoolman → Inventory → AMS remain
-                                const trayTag = tray?.tray_uuid?.toUpperCase();
+                                const trayTag = (tray?.tag_uid || tray?.tray_uuid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx))?.toUpperCase();
                                 const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
                                 const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
                                 const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
@@ -3153,18 +3185,21 @@ function PrinterCard({
                                         data={filamentData}
                                         spoolman={{
                                           enabled: spoolmanEnabled,
-                                          hasUnlinkedSpools,
-                                          linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined,
+                                          linkedSpoolId: trayTag
+                                            ? linkedSpools?.[trayTag]?.id
+                                            : undefined,
                                           spoolmanUrl,
-                                          onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
+                                          onLinkSpool: spoolmanEnabled ? () => {
+                                            const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx)).toUpperCase();
                                             setLinkSpoolModal({
-                                              tagUid: filamentData.tagUid || '',
-                                              trayUuid: uuid,
+                                              tagUid: filamentData.tagUid || linkTag,
+                                              trayUuid: filamentData.trayUuid || '',
                                               printerId: printer.id,
                                               amsId: ams.id,
                                               trayId: slotIdx,
                                             });
                                           } : undefined,
+                                          onUnlinkSpool: linkedSpool?.id ? () => unlinkSpoolMutation.mutate(linkedSpool.id) : undefined,
                                         }}
                                         inventory={spoolmanEnabled ? undefined : (() => {
                                           const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
@@ -3252,13 +3287,13 @@ function PrinterCard({
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                         // Get saved slot preset mapping (for user-configured slots)
                         const slotPreset = slotPresets?.[globalTrayId];
+                        const htSlotId = tray?.id ?? 0;
 
                         // Fill level fallback chain: Spoolman → Inventory → AMS remain
-                        const htTrayTag = tray?.tray_uuid?.toUpperCase();
+                        const htTrayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId))?.toUpperCase();
                         const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
                         const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
-                        const htTraySlotId = tray?.id ?? 0;
-                        const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htTraySlotId);
+                        const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htSlotId);
                         const htInventoryFill = (() => {
                           const sp = htInventoryAssignment?.spool;
                           if (sp && sp.label_weight > 0 && sp.weight_used != null) {
@@ -3285,7 +3320,6 @@ function PrinterCard({
                           fillSource: htFillSource,
                         } : null;
 
-                        const htSlotId = tray?.id ?? 0;
                         // Check if this specific slot is being refreshed
                         const isHtRefreshing = refreshingSlot?.amsId === ams.id &&
                           refreshingSlot?.slotId === htSlotId;
@@ -3397,18 +3431,21 @@ function PrinterCard({
                                     data={filamentData}
                                     spoolman={{
                                       enabled: spoolmanEnabled,
-                                      hasUnlinkedSpools,
-                                      linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined,
+                                      linkedSpoolId: htTrayTag
+                                        ? linkedSpools?.[htTrayTag]?.id
+                                        : undefined,
                                       spoolmanUrl,
-                                      onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
+                                      onLinkSpool: spoolmanEnabled ? () => {
+                                        const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId)).toUpperCase();
                                         setLinkSpoolModal({
-                                          tagUid: filamentData.tagUid || '',
-                                          trayUuid: uuid,
+                                          tagUid: filamentData.tagUid || linkTag,
+                                          trayUuid: filamentData.trayUuid || '',
                                           printerId: printer.id,
                                           amsId: ams.id,
                                           trayId: htSlotId,
                                         });
                                       } : undefined,
+                                      onUnlinkSpool: htLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(htLinkedSpool.id) : undefined,
                                     }}
                                     inventory={spoolmanEnabled ? undefined : (() => {
                                       const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId);
@@ -3525,7 +3562,7 @@ function PrinterCard({
                               const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
                               const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
 
-                              const extTrayTag = extTray.tray_uuid?.toUpperCase();
+                              const extTrayTag = (extTray.tray_uuid || extTray.tag_uid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId))?.toUpperCase();
                               const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
                               const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
                               const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId);
@@ -3590,18 +3627,21 @@ function PrinterCard({
                                       data={extFilamentData}
                                       spoolman={{
                                         enabled: spoolmanEnabled,
-                                        hasUnlinkedSpools,
-                                        linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
+                                        linkedSpoolId: extTrayTag
+                                          ? linkedSpools?.[extTrayTag]?.id
+                                          : undefined,
                                         spoolmanUrl,
-                                        onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
+                                        onLinkSpool: spoolmanEnabled ? () => {
+                                          const linkTag = (extFilamentData.trayUuid || extFilamentData.tagUid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId)).toUpperCase();
                                           setLinkSpoolModal({
-                                            tagUid: extFilamentData.tagUid || '',
-                                            trayUuid: uuid,
+                                            tagUid: extFilamentData.tagUid || linkTag,
+                                            trayUuid: extFilamentData.trayUuid || '',
                                             printerId: printer.id,
                                             amsId: 255,
                                             trayId: slotTrayId,
                                           });
                                         } : undefined,
+                                        onUnlinkSpool: extLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(extLinkedSpool.id) : undefined,
                                       }}
                                       inventory={spoolmanEnabled ? undefined : (() => {
                                         const assignment = onGetAssignment?.(printer.id, 255, slotTrayId);