import { useState, useEffect, useMemo, useRef } from 'react'; import { useOutletContext } from 'react-router-dom'; import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout'; import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client'; import type { MatchedSpool } from '../../hooks/useSpoolBuddyState'; import { useToast } from '../../contexts/ToastContext'; import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon'; import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard'; import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal'; import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal'; function normalizeHexTag(value: string | null | undefined): string { if (!value) return ''; return value.replace(/[^0-9a-f]/gi, '').toUpperCase(); } function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean { const aNorm = normalizeHexTag(a); const bNorm = normalizeHexTag(b); if (!aNorm || !bNorm) return false; if (aNorm === bNorm) return true; // Some readers report shortened UID forms. return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm); } // Color palette for the cycling spool animation const SPOOL_COLORS = [ '#00AE42', '#FF6B35', '#3B82F6', '#EF4444', '#A855F7', '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E', ]; // --- Idle state with slow color-cycling spool --- function IdleSpool() { const { t } = useTranslation(); const [colorIndex, setColorIndex] = useState(0); useEffect(() => { const interval = setInterval(() => { setColorIndex((prev) => (prev + 1) % SPOOL_COLORS.length); }, 5000); return () => clearInterval(interval); }, []); const currentColor = SPOOL_COLORS[colorIndex]; return (
{/* Animated spool with optimized NFC waves */}
{/* NFC wave rings: transform + opacity only for Pi-friendly rendering */}
{[0, 1].map((i) => (
))}
{/* Spool icon with lightweight radial glow */}
{/* Text content */}

{t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}

{t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}

{/* Subtle hint */}
{t('spoolbuddy.dashboard.nfcHint', 'NFC tag will be read automatically')}
); } // --- Offline state --- function DeviceOfflineState() { const { t } = useTranslation(); return (
{/* Offline icon */}

{t('spoolbuddy.status.deviceOffline', 'Device Offline')}

{t('spoolbuddy.status.connectDisplay', 'Connect the SpoolBuddy display to scan spools')}

{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}
); } // --- Main Dashboard --- export function SpoolBuddyDashboard() { const { sbState, selectedPrinterId } = useOutletContext(); const { t } = useTranslation(); const { showToast } = useToast(); const { data: spoolmanSettings } = useQuery({ queryKey: ['spoolman-settings'], queryFn: api.getSpoolmanSettings, staleTime: 5 * 60 * 1000, }); const spoolmanMode = spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url; // Fetch spools for stats, tag lookup, and untagged list const { data: spools = [], refetch: refetchSpools } = useQuery({ queryKey: spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'], queryFn: () => spoolmanMode ? api.getSpoolmanInventorySpools(false) : api.getSpools(false), enabled: spoolmanSettings !== undefined, }); // Kiosk caveat: the SpoolBuddy display is a long-running browser window with // no focus/remount events, so a staleTime alone leaves this cache effectively // permanent. When slot assignments change from another client (Bambuddy main // UI, direct Spoolman edit, AssignToAmsModal on a separate browser), the // kiosk keeps showing the spool as still-assigned forever and isSpoolAssigned // reports stale, disabling the Assign button. Polling every 3 s is cheap // (slot-assignments/all is a tiny DB query) and bounds staleness to a window // operators don't notice. const { data: spoolmanSlotAssignments = [] } = useQuery({ queryKey: ['spoolman-slot-assignments'], queryFn: () => api.getSpoolmanSlotAssignments(), enabled: spoolmanMode, staleTime: 3 * 1000, refetchInterval: 3 * 1000, refetchIntervalInBackground: false, }); // Fetch printers and their statuses for the status badges const { data: printers = [] } = useQuery({ queryKey: ['printers'], queryFn: () => api.getPrinters(), }); const statusQueries = useQueries({ queries: printers.map((printer: Printer) => ({ queryKey: ['printerStatus', printer.id], queryFn: () => api.getPrinterStatus(printer.id), refetchInterval: 10000, select: (data: PrinterStatus) => ({ connected: data?.connected, awaiting_plate_clear: data?.awaiting_plate_clear === true, }), })), }); // Plate-clear: collect printers that are waiting for the operator to confirm. // The kiosk's API key passes the printers:clear_plate gate (not in the // _APIKEY_DENIED_PERMISSIONS set), so no extra perm wiring is needed here. const platesPending = printers .map((printer: Printer, i: number) => ({ printer, pending: statusQueries[i]?.data?.awaiting_plate_clear === true, })) .filter((row: { pending: boolean }) => row.pending); const queryClient = useQueryClient(); const clearPlateMutation = useMutation({ mutationFn: (printerId: number) => api.clearPlate(printerId), onSuccess: (_data, printerId) => { // Optimistically clear the flag so the row vanishes immediately; the // backend already broadcasts a printer_status WS event after clearing, // but we don't want the user to see the row linger while that round-trips. queryClient.setQueryData(['printerStatus', printerId], (old: PrinterStatus | undefined) => old ? { ...old, awaiting_plate_clear: false } : old ); showToast(t('spoolbuddy.dashboard.plateClearedToast', 'Plate marked as cleared'), 'success'); }, onError: () => { showToast(t('spoolbuddy.dashboard.plateClearFailed', 'Could not mark plate as cleared'), 'error'); }, }); const unassignSpoolMutation = useMutation({ mutationFn: (spoolId: number) => api.unassignSpoolmanSlot(spoolId), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] }); void queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] }); }, onError: () => showToast(t('inventory.unassignFailed', 'Failed to unassign spool'), 'error'), }); // Current Spool card state - persists until user closes or new tag detected const [displayedTagId, setDisplayedTagId] = useState(null); const [displayedWeight, setDisplayedWeight] = useState(null); const [hiddenTagId, setHiddenTagId] = useState(null); const [showLinkModal, setShowLinkModal] = useState(false); const [showAssignAmsModal, setShowAssignAmsModal] = useState(false); const [showQuickAddModal, setShowQuickAddModal] = useState(false); const [quickAddBusy, setQuickAddBusy] = useState(false); const [justLinkedSpool, setJustLinkedSpool] = useState | null>(null); // Track current tag from state const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null; const currentWeight = sbState.weight; const weightStable = sbState.weightStable; // Stabilized scale display: only update when change exceeds threshold to prevent bouncing const stableDisplayWeight = useRef(null); const WEIGHT_THRESHOLD = 3; // grams - ignore changes smaller than this if (currentWeight === null) { stableDisplayWeight.current = null; } else if (stableDisplayWeight.current === null || Math.abs(currentWeight - stableDisplayWeight.current) >= WEIGHT_THRESHOLD || weightStable) { stableDisplayWeight.current = currentWeight; } const scaleDisplayValue = stableDisplayWeight.current; // Find spool by tag_id in the loaded spools list const displayedSpool = useMemo((): InventorySpool | null => { if (sbState.matchedSpool?.id) { const byId = spools.find((s) => s.id === sbState.matchedSpool?.id); if (byId) return byId; } if (!displayedTagId) return null; const byTag = spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId)); if (byTag) return byTag; // When a Bambu tray UUID (32-char) is linked, Spoolman stores it in extra.tag and // _map_spoolman_spool routes it to tray_uuid, not tag_uid. tagsEquivalent only // compares tag_uid, so it misses this spool until the device re-scans and // sbState.matchedSpool is populated. Hold the spool returned by the link call // as a temporary bridge. Cast is safe: SpoolInfoCard only reads the 9 fields // present in MatchedSpool; AssignToAmsModal is guarded by !justLinkedSpool below. if (justLinkedSpool) return justLinkedSpool as unknown as InventorySpool; return null; }, [displayedTagId, sbState.matchedSpool, spools, justLinkedSpool]); // Effective spool for the Assign-to-AMS modal: prefer the fully-typed // InventorySpool from the local query cache, fall back to the // WebSocket-delivered MatchedSpool when the cached query hasn't caught up // (Spoolman spool added or unarchived after the dashboard loaded — the // initial fetch with includeArchived=false misses it). Without this // fallback the SpoolInfoCard renders via its own // `displayedSpool ?? sbState.matchedSpool` path while the modal's stricter // guard silently fails to mount, so the "Assign to AMS" button looks // clickable but does nothing on click. MatchedSpool is a 9-field subset // of InventorySpool — slicer_filament* are absent, which is acceptable: // the modal's mismatch check yields 'none' for profile (same as a manual // inventory spool without a preset), and the assign API only needs the // spool id to route to the correct row. const effectiveModalSpool: InventorySpool | null = useMemo(() => { if (displayedSpool && !justLinkedSpool) return displayedSpool; const m = sbState.matchedSpool; if (!m) return null; return { id: m.id, tag_uid: m.tag_uid, material: m.material, subtype: m.subtype, color_name: m.color_name, rgba: m.rgba, brand: m.brand, label_weight: m.label_weight, core_weight: m.core_weight, weight_used: m.weight_used, } as unknown as InventorySpool; }, [displayedSpool, justLinkedSpool, sbState.matchedSpool]); const isSpoolAssigned = spoolmanMode && effectiveModalSpool != null ? spoolmanSlotAssignments.some(a => a.spoolman_spool_id === effectiveModalSpool.id) : false; // Untagged spools for the Link feature const untaggedSpools = useMemo(() => { return spoolmanMode ? spools.filter((s) => !s.tag_uid && !s.tray_uuid && !s.archived_at) : spools.filter((s) => !s.tag_uid && !s.archived_at); }, [spools, spoolmanMode]); // Handle tag detection - show card when tag detected, keep until user closes or new tag useEffect(() => { if (currentTagId) { const isHidden = hiddenTagId === currentTagId; const isDifferentTag = displayedTagId !== null && displayedTagId !== currentTagId; if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) { setDisplayedTagId(currentTagId); setDisplayedWeight(null); setHiddenTagId(null); setJustLinkedSpool(null); } // Update weight when stable and card is visible if (!isHidden && currentWeight !== null && weightStable) { setDisplayedWeight(Math.round(Math.max(0, currentWeight))); } } else { // Tag removed - clear hidden state so same tag can show when re-placed if (hiddenTagId) { setDisplayedTagId(null); setHiddenTagId(null); setDisplayedWeight(null); setJustLinkedSpool(null); } } }, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]); // Auto-sync weight once when known spool first detected const handleCloseSpoolCard = () => { setHiddenTagId(displayedTagId); }; const handleLinkTagToSpool = async (spool: InventorySpool) => { if (!displayedTagId) return; try { if (spoolmanMode) { const tag_uid = sbState.unknownTagUid || undefined; const tray_uuid = (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined; if (!tag_uid && !tray_uuid) { showToast(t('spoolman.linkFailed'), 'error'); return; } const raw = await api.linkTagToSpoolmanSpool(spool.id, { tray_uuid, tag_uid }); const updated = raw as InventorySpool | undefined; if (!updated) { showToast(t('spoolman.linkFailed'), 'error'); return; } const { id, material, subtype, color_name, rgba, brand, label_weight, core_weight, weight_used } = updated; setJustLinkedSpool({ id, material, subtype, color_name, rgba, brand, label_weight, core_weight, weight_used }); showToast(t('spoolman.linkSuccess'), 'success'); } else { await api.linkTagToSpool(spool.id, { tag_uid: displayedTagId, tag_type: 'generic', data_origin: 'nfc_link', }); } refetchSpools(); } catch (e) { console.error('Failed to link tag:', e); showToast(t('spoolman.linkFailed'), 'error'); } finally { setShowLinkModal(false); } }; const handleQuickAddToInventory = async () => { if (!displayedTagId) return; setQuickAddBusy(true); try { const weight = liveWeight ?? displayedWeight; if (spoolmanMode) { const created = await api.createSpoolmanInventorySpool({ material: 'PLA', subtype: null, color_name: null, rgba: null, extra_colors: null, effect_type: null, brand: null, label_weight: 1000, core_weight: 250, core_weight_catalog_id: null, weight_used: 0, slicer_filament: null, slicer_filament_name: null, nozzle_temp_min: null, nozzle_temp_max: null, note: null, added_full: null, last_used: null, encode_time: null, tag_uid: null, tray_uuid: null, data_origin: null, tag_type: null, cost_per_kg: null, last_scale_weight: weight !== null ? Math.round(weight) : null, last_weighed_at: weight !== null ? new Date().toISOString() : null, category: null, low_stock_threshold_pct: null, }); await api.linkTagToSpoolmanSpool(created.id, { tag_uid: sbState.unknownTagUid || undefined, tray_uuid: (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined, }); } else { await api.createSpool({ material: 'PLA', subtype: null, color_name: null, rgba: null, extra_colors: null, effect_type: null, brand: null, label_weight: 1000, core_weight: 250, core_weight_catalog_id: null, weight_used: 0, slicer_filament: null, slicer_filament_name: null, nozzle_temp_min: null, nozzle_temp_max: null, note: null, added_full: null, last_used: null, encode_time: null, tag_uid: displayedTagId, tray_uuid: null, data_origin: 'spoolbuddy', tag_type: 'generic', cost_per_kg: null, last_scale_weight: weight !== null ? Math.round(weight) : null, last_weighed_at: weight !== null ? new Date().toISOString() : null, category: null, low_stock_threshold_pct: null, }); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error('Failed to quick-add spool:', msg); showToast(msg || t('spoolbuddy.errors.quickAddFailed', 'Failed to add spool'), 'error'); } finally { setShowQuickAddModal(false); setQuickAddBusy(false); refetchSpools(); } }; // For unknown tags, use live weight or stored displayed weight const useScaleWeight = currentWeight !== null && (currentTagId === displayedTagId || (currentTagId === null && displayedTagId !== null)); const liveWeight = useScaleWeight ? currentWeight : null; // Stats const totalSpools = spools.length; const materials = new Set(spools.map((s) => s.material)).size; const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size; return (
{/* Compact stats bar */}
{totalSpools} {t('spoolbuddy.inventory.spools', 'Spools')}
{materials} {t('spoolbuddy.spool.material', 'Materials')}
{brands} {t('spoolbuddy.spool.brand', 'Brands')}
{/* Main content: Device (left) + Current Spool (right) */}
{/* Left column */}
{/* Device card */}

{t('spoolbuddy.dashboard.device', 'Device')}

{/* Connection status */}
{sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Disconnected')}
{/* Scale weight */}
{t('spoolbuddy.spool.scaleWeight', 'Scale')}
{scaleDisplayValue !== null ? `${Math.abs(scaleDisplayValue) <= 20 ? 0 : Math.round(Math.max(0, scaleDisplayValue))}g` : '\u2014'}
{/* NFC status */}
NFC
{currentTagId ? t('spoolbuddy.dashboard.tagDetected', 'Tag detected') : t('spoolbuddy.dashboard.noTag', 'No tag')}
{/* Printer status badges */} {printers.length > 0 && (

{t('spoolbuddy.dashboard.printers', 'Printers')}

{printers.map((printer: Printer, i: number) => { const isOnline = statusQueries[i]?.data?.connected ?? false; return (
{printer.name}
); })}
{/* Plate-ready pills — same compact size as the printer badges above so the row stays scannable when multiple printers finish at once. Wraps via flex-wrap. Each pill is independently tappable. */} {platesPending.length > 0 && (
{platesPending.map(({ printer }: { printer: Printer }) => ( ))}
)}
)}
{/* Right column: Current Spool */}

{t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}

{!sbState.deviceOnline ? ( ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? ( { const s = displayedSpool ?? sbState.matchedSpool!; return { id: s.id, tag_uid: displayedTagId, material: s.material, subtype: s.subtype, color_name: s.color_name, rgba: s.rgba, brand: s.brand, label_weight: s.label_weight, core_weight: s.core_weight, weight_used: s.weight_used, }; })()} scaleWeight={liveWeight ?? displayedWeight} onSyncWeight={() => refetchSpools()} onAssignToAms={() => setShowAssignAmsModal(true)} isAssigned={isSpoolAssigned} onUnassignFromAms={ (isSpoolAssigned && displayedSpool?.id != null) ? () => unassignSpoolMutation.mutate(displayedSpool!.id) : undefined } onClose={handleCloseSpoolCard} /> ) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? ( 0 ? () => setShowLinkModal(true) : undefined} onAddToInventory={() => setShowQuickAddModal(true)} onClose={handleCloseSpoolCard} /> ) : ( )}
{/* Assign to AMS Modal — uses effectiveModalSpool which falls back to sbState.matchedSpool when the cached inventory query hasn't caught up to the matched spool (newly-added or unarchived in Spoolman). The !justLinkedSpool guard still excludes the freshly-linked synthetic spool because that path goes through a different flow. */} {effectiveModalSpool && !justLinkedSpool && displayedTagId && ( setShowAssignAmsModal(false)} spool={effectiveModalSpool} printerId={selectedPrinterId} spoolmanMode={spoolmanMode} /> )} {/* Link Tag to Spool Modal */} {displayedTagId && ( setShowLinkModal(false)} tagId={displayedTagId} untaggedSpools={untaggedSpools} onLink={handleLinkTagToSpool} /> )} {/* Quick-add to Inventory Modal */} {showQuickAddModal && displayedTagId && (

{t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}

{/* Hint */}

{t('spoolbuddy.modal.quickAddHint', 'For best results, add the spool in the Bambuddy web interface first (with material, color, brand), then use "Assign Spool" here to assign the NFC tag.')}

{t('spoolbuddy.modal.quickAddDesc', 'This will create a basic PLA spool entry with this NFC tag. You can edit the details later in Bambuddy.')}

{displayedTagId}

)}
); }