mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
[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>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} · 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'} · #{spool.id}
|
||||
{spool.filament_material || 'Unknown'} · #{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>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user