diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py index 0a59ea53b..125b3861d 100644 --- a/backend/app/api/routes/spoolman.py +++ b/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 tray_uuid %s", spool_id, tray_uuid) - return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"} + 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("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") diff --git a/backend/tests/integration/test_spoolman_api.py b/backend/tests/integration/test_spoolman_api.py index fbe3f1595..0c2c3d1bb 100644 --- a/backend/tests/integration/test_spoolman_api.py +++ b/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 # ========================================================================= diff --git a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx index 26997929c..7792ae772 100644 --- a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx +++ b/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(); 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(); @@ -120,42 +98,34 @@ describe('LinkSpoolModal', () => { }); }); - it('displays untagged spools only', async () => { + it('displays unlinked spools from Spoolman', async () => { render(); 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(); - 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(); 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(); 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(); 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(); 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(); 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'); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7dbd9ee53..79829f9ed 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3527,10 +3527,14 @@ export const api = { request('/spoolman/spools/unlinked'), getLinkedSpools: () => request('/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'), diff --git a/frontend/src/components/FilamentHoverCard.tsx b/frontend/src/components/FilamentHoverCard.tsx index bb2757690..2802bd2c5 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/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(null); const cardRef = useRef(null); const timeoutRef = useRef | null>(null); @@ -258,63 +260,82 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp {/* Spoolman section - only show if enabled */} - {spoolman?.enabled && data.trayUuid && ( + {spoolman?.enabled && (
{/* Tray UUID with copy button */}
{t('spoolman.spoolId')} - + {data.trayUuid ? ( + + ) : ( + — + )}
{/* Open in Spoolman button (when already linked) */} {spoolman.linkedSpoolId && spoolman.spoolmanUrl && ( - 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')} - > - - {t('spoolman.openInSpoolman')} - + <> + 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')} + > + + {t('spoolman.openInSpoolman')} + + + {spoolman.onUnlinkSpool && data.vendor !== 'Bambu Lab' && ( + + )} + )} {/* Link Spool button (when not linked) */} - {!spoolman.linkedSpoolId && spoolman.onLinkSpool && ( + {!spoolman.linkedSpoolId && (
)} + + {/* Unlink Confirmation Dialog */} + {showUnlinkConfirm && ( +
setShowUnlinkConfirm(false)}> +
+
e.stopPropagation()} + > +
+
+

+ {t('spoolman.unlinkConfirmTitle')} +

+

+ {t('spoolman.unlinkConfirmMessage')} +

+
+
+ + +
+
+
+
+ )}
); } diff --git a/frontend/src/components/LinkSpoolModal.tsx b/frontend/src/components/LinkSpoolModal.tsx index cd5eaa4d9..e15356e7b 100644 --- a/frontend/src/components/LinkSpoolModal.tsx +++ b/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

- {t('inventory.linkToSpool')} + {t('spoolman.selectSpool')}

AMS {amsId} T{trayId} · Printer #{printerId} @@ -115,28 +110,27 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a {t('inventory.noSpoolsMatch')}

) : ( - filteredSpools.map((spool: InventorySpool) => ( + filteredSpools.map((spool: UnlinkedSpool) => ( )) diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index bed8172a7..2f183a67a 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e5a651d66..b6115b1d8 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index e62339836..5bf504bb5 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 8d3eec316..4e7a7acd6 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 76e13af65..c41bb583e 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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: '重量', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index ee1fff864..bf89e865c 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 8688e2247..65520feda 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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: '重量', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 26784f783..f39a3e6ba 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/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);