[Fix]: Send Bambu RFID Tags to Spoolman, Allow Unlink in Manual Mode, Stop Location Clearing for Generic Spools (#719)

[Fix]: Send Bambu RFID Tags to Spoolman, Allow Unlink in Manual Mode, Stop Location Clearing for Generic Spools (#719)
This commit is contained in:
Dakota G
2026-03-16 15:45:06 +01:00
committed by GitHub
parent 92c3ce3993
commit ac7cfe2930
6 changed files with 56 additions and 11 deletions
+5
View File
@@ -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(
@@ -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,
@@ -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(<LinkSpoolModal {...defaultProps} />);
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(<LinkSpoolModal {...defaultProps} trayUuid="" />);
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',
@@ -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')}
</a>
{spoolman.onUnlinkSpool && data.vendor !== 'Bambu Lab' && (
{spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && (
<button
onClick={(e) => {
e.stopPropagation();
+4 -4
View File
@@ -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"
/>
</div>
{(tagUid || trayUuid) && (
<p className="text-xs text-bambu-gray mt-2 font-mono truncate" title={tagUid || trayUuid}>
Tag: {tagUid || trayUuid}
{(trayUuid || tagUid) && (
<p className="text-xs text-bambu-gray mt-2 font-mono truncate" title={trayUuid || tagUid}>
Tag: {trayUuid || tagUid}
</p>
)}
</div>
+17 -1
View File
@@ -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<string, LinkedSpoolInfo>;
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 ? {