diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index 3e3de5976..e89d884d2 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -9,6 +9,7 @@ import httpx logger = logging.getLogger(__name__) +BAMBU_RFID_TAG_LENGTH = 32 @dataclass class SpoolmanSpool: @@ -553,6 +554,10 @@ class SpoolmanClient: else: spool_uuid = "" + # Only clear location for Bambu Lab spools (those with a stored 32-character RFID tag). + if len(spool_uuid) != BAMBU_RFID_TAG_LENGTH: + continue + # If this spool's UUID is not in the current AMS, clear its location if spool_uuid not in current_tray_uuids: logger.info( diff --git a/backend/tests/unit/services/test_spoolman_service.py b/backend/tests/unit/services/test_spoolman_service.py index 8697a88c2..ffcc66766 100644 --- a/backend/tests/unit/services/test_spoolman_service.py +++ b/backend/tests/unit/services/test_spoolman_service.py @@ -298,11 +298,15 @@ class TestSpoolmanClient: async def test_clear_location_for_removed_spools_with_cached_spools(self, client): """Verify clear_location_for_removed_spools uses cached spools.""" cached = [ - {"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"TAG1"'}}, - {"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"TAG2"'}}, - {"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"TAG3"'}}, + {"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"A1B2C3D4E5F60718293A4B5C6D7E8F90"'}}, + {"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"B1C2D3E4F5061728394A5B6C7D8E9F01"'}}, + {"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"C1D2E3F40516273849A5B6C7D8E9F012"'}}, ] - current_tags = {"TAG1", "TAG2"} # TAG3 was removed + # Tag 3 was cleared, so only tags 1 and 2 are current + current_tags = { + "A1B2C3D4E5F60718293A4B5C6D7E8F90", + "B1C2D3E4F5061728394A5B6C7D8E9F01", + } with ( patch.object(client, "get_spools", AsyncMock()) as mock_get, diff --git a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx index 8aa3129fe..a7a50bcb1 100644 --- a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx +++ b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx @@ -117,7 +117,7 @@ describe('LinkSpoolModal', () => { }); describe('linking', () => { - it('calls linkSpool on spool click', async () => { + it('uses trayUuid when linking if present (Bambu spool path)', async () => { render(); await waitFor(() => { @@ -126,6 +126,25 @@ describe('LinkSpoolModal', () => { fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!); + await waitFor(() => { + expect(api.linkSpool).toHaveBeenCalledWith(1, { + spoolTag: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4', + printerId: 1, + amsId: 0, + trayId: 0, + }); + }); + }); + + it('falls back to tagUid when trayUuid is missing (generic spool path)', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!); + await waitFor(() => { expect(api.linkSpool).toHaveBeenCalledWith(1, { spoolTag: 'ABCD1234', diff --git a/frontend/src/components/FilamentHoverCard.tsx b/frontend/src/components/FilamentHoverCard.tsx index cb7c501fb..3984ee48f 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/frontend/src/components/FilamentHoverCard.tsx @@ -21,6 +21,7 @@ interface SpoolmanConfig { 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) + syncMode?: string | null; // If auto-sync is enabled, we may want to hide the unlink option for Bambu spools } interface InventoryConfig { @@ -305,7 +306,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp {t('spoolman.openInSpoolman')} - {spoolman.onUnlinkSpool && data.vendor !== 'Bambu Lab' && ( + {spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && ( { e.stopPropagation(); diff --git a/frontend/src/components/LinkSpoolModal.tsx b/frontend/src/components/LinkSpoolModal.tsx index 25ec4129a..458b6460b 100644 --- a/frontend/src/components/LinkSpoolModal.tsx +++ b/frontend/src/components/LinkSpoolModal.tsx @@ -22,7 +22,7 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a const queryClient = useQueryClient(); const { showToast } = useToast(); const [search, setSearch] = useState(''); - const spoolTag = tagUid || trayUuid; + const spoolTag = trayUuid || tagUid; const { data: spools, isLoading } = useQuery({ queryKey: ['unlinked-spools'], @@ -97,9 +97,9 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a className="w-full pl-9 pr-3 py-2 bg-bambu-dark rounded-lg border border-white/10 text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green" /> - {(tagUid || trayUuid) && ( - - Tag: {tagUid || trayUuid} + {(trayUuid || tagUid) && ( + + Tag: {trayUuid || tagUid} )} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index c35ecd2b7..07eb49f07 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1530,6 +1530,7 @@ function PrinterCard({ spoolmanEnabled = false, linkedSpools, spoolmanUrl, + spoolmanSyncMode, onGetAssignment, onUnassignSpool, timeFormat = 'system', @@ -1553,6 +1554,7 @@ function PrinterCard({ hasUnlinkedSpools?: boolean; linkedSpools?: Record; spoolmanUrl?: string | null; + spoolmanSyncMode?: string | null; spoolAssignments?: SpoolAssignment[]; onGetAssignment?: (printerId: number, amsId: number, trayId: number) => SpoolAssignment | undefined; onUnassignSpool?: (printerId: number, amsId: number, trayId: number) => void; @@ -3176,7 +3178,7 @@ function PrinterCard({ const slotPreset = slotPresets?.[globalTrayId]; // Fill level fallback chain: Spoolman → Inventory → AMS remain - const trayTag = (tray?.tag_uid || tray?.tray_uuid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx))?.toUpperCase(); + const trayTag = (tray?.tray_uuid || tray?.tag_uid || 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); @@ -3303,6 +3305,7 @@ function PrinterCard({ ? linkedSpools?.[trayTag]?.id : undefined, spoolmanUrl, + syncMode: spoolmanSyncMode, onLinkSpool: spoolmanEnabled ? () => { const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx)).toUpperCase(); setLinkSpoolModal({ @@ -3619,6 +3622,7 @@ function PrinterCard({ ? linkedSpools?.[htTrayTag]?.id : undefined, spoolmanUrl, + syncMode: spoolmanSyncMode, onLinkSpool: spoolmanEnabled ? () => { const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId)).toUpperCase(); setLinkSpoolModal({ @@ -3832,6 +3836,7 @@ function PrinterCard({ ? linkedSpools?.[extTrayTag]?.id : undefined, spoolmanUrl, + syncMode: spoolmanSyncMode, onLinkSpool: spoolmanEnabled ? () => { const linkTag = (extFilamentData.trayUuid || extFilamentData.tagUid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId)).toUpperCase(); setLinkSpoolModal({ @@ -5715,6 +5720,15 @@ export function PrintersPage() { }); const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected; + // Fetch Spoolman settings to get sync mode + const { data: spoolmanSettings } = useQuery({ + queryKey: ['spoolman-settings'], + queryFn: api.getSpoolmanSettings, + enabled: !!spoolmanEnabled, + staleTime: 60 * 1000, // 1 minute + }); + const spoolmanSyncMode = spoolmanSettings?.spoolman_sync_mode; + // Fetch unlinked spools to know if link button should be enabled const { data: unlinkedSpools } = useQuery({ queryKey: ['unlinked-spools'], @@ -6064,6 +6078,7 @@ export function PrintersPage() { hasUnlinkedSpools={hasUnlinkedSpools} linkedSpools={linkedSpools} spoolmanUrl={spoolmanStatus?.url} + spoolmanSyncMode={spoolmanSyncMode} onGetAssignment={getAssignment} onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })} timeFormat={settings?.time_format || 'system'} @@ -6092,6 +6107,7 @@ export function PrintersPage() { hasUnlinkedSpools={hasUnlinkedSpools} linkedSpools={linkedSpools} spoolmanUrl={spoolmanStatus?.url} + spoolmanSyncMode={spoolmanSyncMode} onGetAssignment={getAssignment} onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })} amsThresholds={settings ? {
- Tag: {tagUid || trayUuid} + {(trayUuid || tagUid) && ( +
+ Tag: {trayUuid || tagUid}