import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import { compareFwVersions } from '../utils/firmwareVersion'; import { formatPrintName } from '../utils/printName'; import { computePopoverPosition } from '../utils/popoverPosition'; // AMS drying popover dimensions — w-[240px] on the popover, estimated height // covers header + filament select + temp slider + duration + rotate-tray // toggle + buttons. Over-estimating is fine (flip-above kicks in slightly // earlier); under-estimating leaves the popover clipped off the bottom (the // original bug at #1447). const DRYING_POPOVER_WIDTH = 240; const DRYING_POPOVER_ESTIMATED_HEIGHT = 320; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { useAuth } from '../contexts/AuthContext'; import { Plus, Link, Unlink, Signal, Clock, MoreVertical, Trash2, RefreshCw, RotateCw, Box, HardDrive, AlertTriangle, AlertCircle, Terminal, Power, PowerOff, Zap, Wrench, ChevronDown, Filter, Pencil, ArrowUp, ArrowDown, Layers, Video, Search, Loader2, Square, Pause, Play, X, Fan, Wind, AirVent, Download, ScanSearch, CheckCircle, CheckSquare, XCircle, User, Home, Printer as PrinterIcon, Info, Cable, Flame, Snowflake, Gauge, DoorOpen, DoorClosed, MoveVertical, LogIn, LogOut, MoreHorizontal, SlidersHorizontal, Stethoscope, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { api, discoveryApi, firmwareApi, withStreamToken, ApiError } from '../api/client'; import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date'; import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug, PrinterDiagnosticResult } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinterToolbar'; import { FileManagerModal } from '../components/FileManagerModal'; import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer'; import { MQTTDebugModal } from '../components/MQTTDebugModal'; import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal'; import { PrinterQueueWidget } from '../components/PrinterQueueWidget'; import { AMSHistoryModal } from '../components/AMSHistoryModal'; import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard'; import { LinkSpoolModal } from '../components/LinkSpoolModal'; import { AssignSpoolModal } from '../components/AssignSpoolModal'; import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal'; import { useToast } from '../contexts/ToastContext'; import { ChamberLight } from '../components/icons/ChamberLight'; import { PlateClearedIcon } from '../components/icons/PlateClearedIcon'; import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal'; import { FileUploadModal } from '../components/FileUploadModal'; import { PrintModal } from '../components/PrintModal'; import { PrinterInfoModal } from '../components/PrinterInfoModal'; import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool } from '../utils/amsHelpers'; import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer'; import { FilamentSlotCircle } from '../components/FilamentSlotCircle'; import { Collapsible } from '../components/Collapsible'; import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic'; import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors'; export interface SpoolmanSlotAssignmentRow { printer_id: number; ams_id: number; tray_id: number; spoolman_spool_id: number; } // Color names resolve via getColorName() which reads the backend color_catalog // (loaded once by ColorCatalogProvider). No hardcoded tables here — see #857. // Format K value with 3 decimal places, default to 0.020 if null function formatKValue(k: number | null | undefined): string { const value = k ?? 0.020; return value.toFixed(3); } // Nozzle side indicators (Bambu Lab style - square badge with L/R) function NozzleBadge({ side }: { side: 'L' | 'R' }) { const { mode } = useTheme(); // Light mode: #e7f5e9 (light green), Dark mode: #1a4d2e (dark green) const bgColor = mode === 'dark' ? '#1a4d2e' : '#e7f5e9'; return ( {side} ); } // Expand nozzle type codes to material names // Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01") // Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide function nozzleTypeName(type: string, t: (key: string) => string): string { if (!type) return ''; // Full text names (from main nozzle info) if (type.includes('hardened')) return t('printers.nozzleHardenedSteel'); if (type.includes('stainless')) return t('printers.nozzleStainlessSteel'); if (type.includes('tungsten')) return t('printers.nozzleTungstenCarbide'); // 4-char codes (e.g. "HS01"): last 2 digits = material if (type.length >= 4) { const material = type.slice(2, 4); if (material === '00') return t('printers.nozzleStainlessSteel'); if (material === '01') return t('printers.nozzleHardenedSteel'); if (material === '05') return t('printers.nozzleTungstenCarbide'); } // 2-digit numeric codes if (type === '00') return t('printers.nozzleStainlessSteel'); if (type === '01') return t('printers.nozzleHardenedSteel'); if (type === '05') return t('printers.nozzleTungstenCarbide'); // 2-char alpha codes: H prefix = hardened steel if (type.startsWith('H')) return t('printers.nozzleHardenedSteel'); return type; } // Parse flow type from nozzle type code // HH = high flow, HS = standard/normal function nozzleFlowName(type: string, t: (key: string) => string): string { if (!type) return ''; if (type.startsWith('HH')) return t('printers.nozzleHighFlow'); if (type.startsWith('HS')) return t('printers.nozzleStandardFlow'); return ''; } // Per-slot hover card for nozzle rack // activeStatus: when true, show "Active" instead of "Mounted"/"Docked" (for hotend nozzles) function NozzleSlotHoverCard({ slot, index, activeStatus, filamentName, children }: { slot: import('../api/client').NozzleRackSlot; index: number; activeStatus?: boolean; filamentName?: string; children: React.ReactNode; }) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); const triggerRef = useRef(null); const cardRef = useRef(null); const timeoutRef = useRef | null>(null); const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type; const isMounted = slot.stat === 1; useEffect(() => { if (isVisible && triggerRef.current && cardRef.current) { const triggerRect = triggerRef.current.getBoundingClientRect(); const cardHeight = cardRef.current.offsetHeight; const headerHeight = 56; const spaceAbove = triggerRect.top - headerHeight; const spaceBelow = window.innerHeight - triggerRect.bottom; if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) { setPosition('bottom'); } else { setPosition('top'); } } }, [isVisible]); const handleMouseEnter = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(true), 80); }; const handleMouseLeave = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(false), 100); }; useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); const filamentCss = parseFilamentColor(slot.filament_color); const typeFull = nozzleTypeName(slot.nozzle_type, t); const flowFull = nozzleFlowName(slot.nozzle_type, t); return (
{children} {isVisible && (
{isEmpty ? (
Slot {index + 1} — Empty
) : (
{/* Diameter */}
{t('printers.nozzleDiameter')} {slot.nozzle_diameter} mm
{/* Type */} {typeFull && (
{t('printers.nozzleType')} {typeFull}
)} {/* Flow (hide if empty) */} {flowFull && (
{t('printers.nozzleFlow')} {flowFull}
)} {/* Status badge */}
{t('printers.nozzleStatus')} {activeStatus ? t('printers.nozzleActive') : isMounted ? t('printers.nozzleMounted') : t('printers.nozzleDocked')}
{/* Wear (hide if null) */} {slot.wear != null && (
{t('printers.nozzleWear')} {slot.wear}%
)} {/* Max Temp (hide if 0) */} {slot.max_temp > 0 && (
{t('printers.nozzleMaxTemp')} {slot.max_temp}°C
)} {/* Serial (hide if empty) */} {slot.serial_number && (
{t('printers.nozzleSerial')} {slot.serial_number}
)} {/* Filament: material type + color swatch (hide if no color) */} {(filamentCss || slot.filament_type) && (
{t('printers.nozzleFilament')}
{filamentCss && (
)} {filamentName || slot.filament_type || slot.filament_id || ''}
)}
)}
{/* Arrow pointer */}
)}
); } // Dual-nozzle hover card showing L and R nozzle details side by side function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, filamentInfo, children }: { leftSlot?: import('../api/client').NozzleRackSlot; rightSlot?: import('../api/client').NozzleRackSlot; activeNozzle: 'L' | 'R'; filamentInfo?: Record; children: React.ReactNode; }) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); const triggerRef = useRef(null); const cardRef = useRef(null); const timeoutRef = useRef | null>(null); useEffect(() => { if (isVisible && triggerRef.current && cardRef.current) { const triggerRect = triggerRef.current.getBoundingClientRect(); const cardHeight = cardRef.current.offsetHeight; const headerHeight = 56; const spaceAbove = triggerRect.top - headerHeight; const spaceBelow = window.innerHeight - triggerRect.bottom; if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) { setPosition('bottom'); } else { setPosition('top'); } } }, [isVisible]); const handleMouseEnter = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(true), 80); }; const handleMouseLeave = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(false), 100); }; useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); if (!leftSlot && !rightSlot) return <>{children}; const renderColumn = (slot: import('../api/client').NozzleRackSlot, side: 'L' | 'R') => { const isActive = activeNozzle === side; const typeFull = nozzleTypeName(slot.nozzle_type, t); const flowFull = nozzleFlowName(slot.nozzle_type, t); const filamentCss = parseFilamentColor(slot.filament_color); const filamentName = slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined; return (
{side === 'L' ? t('common.left') : t('common.right')}
{slot.nozzle_diameter && (
{t('printers.nozzleDiameter')} {slot.nozzle_diameter} mm
)} {typeFull && (
{t('printers.nozzleType')} {typeFull}
)} {flowFull && (
{t('printers.nozzleFlow')} {flowFull}
)}
{t('printers.nozzleStatus')} {isActive ? t('printers.nozzleActive') : t('printers.nozzleIdle')}
{slot.wear != null && (
{t('printers.nozzleWear')} {slot.wear}%
)} {/* Serial and max temp only available on the right (removable) nozzle */} {side === 'R' && slot.max_temp > 0 && (
{t('printers.nozzleMaxTemp')} {slot.max_temp}°C
)} {side === 'R' && slot.serial_number && (
{t('printers.nozzleSerial')} {slot.serial_number}
)} {(filamentCss || slot.filament_type || slot.filament_id) && (
{t('printers.nozzleFilament')}
{filamentCss && (
)} {filamentName || slot.filament_type || slot.filament_id || ''}
)}
); }; return (
{children} {isVisible && (
{leftSlot && renderColumn(leftSlot, 'L')} {leftSlot && rightSlot &&
} {rightSlot && renderColumn(rightSlot, 'R')}
{/* Arrow pointer */}
)}
); } // H2C Nozzle Rack Card — compact single row showing 6-position tool-changer dock function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client').NozzleRackSlot[]; filamentInfo?: Record }) { const { t } = useTranslation(); // Rack nozzles only (IDs >= 2) — excludes L/R hotend nozzles (IDs 0, 1). // H2C rack slot IDs are fixed at 16..21. When a nozzle is picked up into the // hotend the firmware omits that rack ID entirely, so we must map by the fixed // base — computing it from min(present IDs) shifts everything left when slot 16 // is the one currently mounted (#943). const rackNozzles = slots.filter(s => s.id >= 2); const RACK_SIZE = 6; const RACK_BASE_ID = 16; const rackSlots: (import('../api/client').NozzleRackSlot)[] = Array.from( { length: RACK_SIZE }, (_, i) => rackNozzles.find(s => s.id === RACK_BASE_ID + i) ?? { id: -(i + 1), nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '', }, ); return (

{t('printers.nozzleRack')}

{rackSlots.map((slot, i) => { const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type; const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null; const lightBg = filamentBg ? isLightColor(slot.filament_color) : false; return ( = 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
{isEmpty ? '—' : (slot.nozzle_diameter || '?')}
); })}
); } // Water drop SVG - empty outline (Bambu Lab style from bambu-humidity) function WaterDropEmpty({ className }: { className?: string }) { return ( ); } // Water drop SVG - half filled with blue water (Bambu Lab style from bambu-humidity) function WaterDropHalf({ className }: { className?: string }) { return ( ); } // Water drop SVG - fully filled with blue water (Bambu Lab style from bambu-humidity) function WaterDropFull({ className }: { className?: string }) { return ( ); } // Thermometer SVG - empty outline function ThermometerEmpty({ className }: { className?: string }) { return ( ); } // Thermometer SVG - half filled (gold - same as humidity fair) function ThermometerHalf({ className }: { className?: string }) { return ( ); } // Thermometer SVG - fully filled (red - same as humidity bad) function ThermometerFull({ className }: { className?: string }) { return ( ); } // Nozzle icon - schematic hot-end view (filament body + heater block + tip). // Added for visual parity with the thermometer icons on the dual-nozzle card // that previously had no icon at all (#1115, design by @m4rtini2). function NozzleIcon({ className }: { className?: string }) { return ( ); } // Heater thermometer icon - filled when heating, outline when off interface HeaterThermometerProps { className?: string; color: string; // The color class (e.g., "text-orange-400") isHeating: boolean; } function HeaterThermometer({ className, color, isHeating }: HeaterThermometerProps) { // Extract the actual color from Tailwind class for SVG fill const colorMap: Record = { 'text-orange-400': '#fb923c', 'text-blue-400': '#60a5fa', 'text-green-400': '#4ade80', }; const fillColor = colorMap[color] || '#888'; // Glow style when heating const glowStyle = isHeating ? { filter: `drop-shadow(0 0 4px ${fillColor}) drop-shadow(0 0 8px ${fillColor})`, } : {}; if (isHeating) { // Filled thermometer with glow - heater is ON return ( ); } // Empty thermometer - heater is OFF return ( ); } // Humidity indicator with water drop that fills based on level (Bambu Lab style) // Reference: https://github.com/theicedmango/bambu-humidity interface HumidityIndicatorProps { humidity: number | string; goodThreshold?: number; // <= this is green fairThreshold?: number; // <= this is orange, > is red onClick?: () => void; compact?: boolean; // Smaller version for grid layout } function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick, compact }: HumidityIndicatorProps) { const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity; const good = typeof goodThreshold === 'number' ? goodThreshold : 40; const fair = typeof fairThreshold === 'number' ? fairThreshold : 60; // Status thresholds (configurable via settings) // Good: ≤goodThreshold (green #22a352), Fair: ≤fairThreshold (gold #d4a017), Bad: >fairThreshold (red #c62828) let textColor: string; let statusText: string; if (isNaN(humidityValue)) { textColor = '#C3C2C1'; statusText = 'Unknown'; } else if (humidityValue <= good) { textColor = '#22a352'; // Green - Good statusText = 'Good'; } else if (humidityValue <= fair) { textColor = '#d4a017'; // Gold - Fair statusText = 'Fair'; } else { textColor = '#c62828'; // Red - Bad statusText = 'Bad'; } // Fill level based on status: Good=Empty (dry), Fair=Half, Bad=Full (wet) let DropComponent: React.FC<{ className?: string }>; if (isNaN(humidityValue)) { DropComponent = WaterDropEmpty; } else if (humidityValue <= good) { DropComponent = WaterDropEmpty; // Good - empty drop (dry) } else if (humidityValue <= fair) { DropComponent = WaterDropHalf; // Fair - half filled } else { DropComponent = WaterDropFull; // Bad - full (too humid) } return ( ); } // Temperature indicator with dynamic icon and coloring interface TemperatureIndicatorProps { temp: number; goodThreshold?: number; // <= this is blue fairThreshold?: number; // <= this is orange, > is red onClick?: () => void; compact?: boolean; // Smaller version for grid layout } function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick, compact }: TemperatureIndicatorProps) { // Ensure thresholds are numbers const good = typeof goodThreshold === 'number' ? goodThreshold : 28; const fair = typeof fairThreshold === 'number' ? fairThreshold : 35; let textColor: string; let statusText: string; let ThermoComponent: React.FC<{ className?: string }>; if (temp <= good) { textColor = '#22a352'; // Green - good (same as humidity) statusText = 'Good'; ThermoComponent = ThermometerEmpty; } else if (temp <= fair) { textColor = '#d4a017'; // Gold - fair (same as humidity) statusText = 'Fair'; ThermoComponent = ThermometerHalf; } else { textColor = '#c62828'; // Red - bad (same as humidity) statusText = 'Bad'; ThermoComponent = ThermometerFull; } return ( ); } function getAmsLabel(amsId: number | string, trayCount: number): string { // Ensure amsId is a number (backend might send string) const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId; const safeId = isNaN(id) ? 0 : id; const isHt = trayCount === 1; // AMS-HT uses IDs starting at 128, regular AMS uses 0-3 const normalizedId = safeId >= 128 ? safeId - 128 : safeId; const letter = String.fromCharCode(65 + normalizedId); // 0=A, 1=B, 2=C, 3=D return isHt ? `HT-${letter}` : `AMS-${letter}`; } /** Classify an empty AMS slot for UI rendering (#1322 follow-up). * * "physical" — firmware positively confirmed no spool (state 9 or 10). The * bambu_mqtt handler now promotes tray_exist_bits=0 slots to state=9, so * every empty-by-bitmask slot lands here regardless of firmware payload * shape. * * "reset" — tray_type is missing/empty but firmware hasn't confirmed * emptiness (state is null, 3, or any non-9/10 value). Typically a slot * the user cleared with "Reset Slot" where a physical spool may still be * loaded but unassigned. * * Returns null when the slot is loaded (tray_type is present). */ function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null } | null | undefined): 'physical' | 'reset' | null { if (tray?.tray_type) return null; return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset'; } function CoverImage({ url, printName }: { url: string | null; printName?: string }) { const { t } = useTranslation(); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); const [showOverlay, setShowOverlay] = useState(false); // Cache-bust the image URL when the print name changes so the browser // fetches the new cover instead of serving the stale cached image. const cacheBustedUrl = useMemo(() => { if (!url) return null; const sep = url.includes('?') ? '&' : '?'; return withStreamToken(`${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`); }, [url, printName]); // Reset loaded/error state when the image URL changes useEffect(() => { setLoaded(false); setError(false); }, [cacheBustedUrl]); return ( <>
cacheBustedUrl && loaded && setShowOverlay(true)} > {cacheBustedUrl && !error ? ( <> {t('printers.printPreview')} setLoaded(true)} onError={() => setError(true)} /> {!loaded && } ) : ( )}
{/* Cover Image Overlay */} {showOverlay && cacheBustedUrl && (
setShowOverlay(false)} >
{t('printers.printPreview')} {printName && (

{printName}

)}
)} ); } interface PrinterMaintenanceInfo { due_count: number; warning_count: number; total_print_hours: number; } // Status summary bar component - uses queryClient to read cached statuses function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { const { t } = useTranslation(); const queryClient = useQueryClient(); // Subscribe to query cache changes to re-render when status updates // Throttled to prevent rapid re-renders from causing tab crashes const [cacheTick, setCacheTick] = useState(0); useEffect(() => { let pending = false; const unsubscribe = queryClient.getQueryCache().subscribe(() => { if (!pending) { pending = true; requestAnimationFrame(() => { setCacheTick(t => t + 1); pending = false; }); } }); return () => unsubscribe(); }, [queryClient]); const { counts, nextFinish } = useMemo(() => { let printing = 0; let paused = 0; let finished = 0; let idle = 0; let offline = 0; let loading = 0; let error = 0; let nextPrinterName: string | null = null; let nextRemainingMin: number | null = null; let nextProgress: number = 0; printers?.forEach((printer) => { const status = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null; progress: number | null; hms_errors?: HMSError[] }>(['printerStatus', printer.id]); if (status === undefined) { // Status not yet loaded - don't count as offline yet loading++; } else if (!status.connected) { offline++; } else { // Count printers with active HMS errors as problems const knownHmsCount = status.hms_errors ? filterKnownHMSErrors(status.hms_errors).length : 0; if (knownHmsCount > 0) { error++; } switch (status.state) { case 'RUNNING': printing++; if (status.remaining_time != null && status.remaining_time > 0) { if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) { nextRemainingMin = status.remaining_time; nextPrinterName = printer.name; nextProgress = status.progress || 0; } } break; case 'PAUSE': paused++; break; case 'FINISH': finished++; break; case 'FAILED': // FAILED is the printer's terminal gcode_state after a print stops — // including user cancellations, where there's no actual fault. Only // count it as a "problem" when an HMS error is also active; otherwise // it's just a print that ended unsuccessfully and the plate needs // clearing (same as FINISH from the operator's perspective). if (knownHmsCount > 0) { // Already counted above } else { finished++; } break; default: idle++; break; } } }); return { counts: { printing, paused, finished, idle, offline, loading, error, total: (printers?.length || 0) }, nextFinish: nextPrinterName && nextRemainingMin ? { name: nextPrinterName, remainingMin: nextRemainingMin, progress: nextProgress } : null, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [printers, queryClient, cacheTick]); if (!printers?.length) return null; const badges: { count: number; dot: string; label: string }[] = [ { count: counts.printing, dot: 'bg-bambu-green animate-pulse', label: t('printers.status.printing').toLowerCase() }, { count: counts.paused, dot: 'bg-status-warning', label: t('printers.status.paused', 'paused').toLowerCase() }, { count: counts.finished, dot: 'bg-blue-400', label: t('printers.status.finished', 'finished').toLowerCase() }, { count: counts.idle, dot: counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500', label: t('printers.status.available').toLowerCase() }, { count: counts.error, dot: 'bg-status-error', label: t('printers.status.problem').toLowerCase() }, { count: counts.offline, dot: 'bg-gray-400', label: t('printers.status.offline').toLowerCase() }, ]; return (
{badges.map(({ count, dot, label }) => count > 0 && (
{count} {label}
))} {nextFinish && ( <>
{t('printers.nextAvailable')}: {nextFinish.name}
{Math.round(nextFinish.progress)}% ({formatDuration(nextFinish.remainingMin * 60)})
)}
); } type SortOption = 'name' | 'status' | 'model' | 'location'; type ViewMode = 'expanded' | 'compact'; type ToolbarDropdownOption = { value: T; label: string; }; function ToolbarDropdown({ value, options, onChange, fullWidth = false, }: { value: T; options: ToolbarDropdownOption[]; onChange: (value: T) => void; fullWidth?: boolean; }) { const [isOpen, setIsOpen] = useState(false); const selectedOption = options.find(option => option.value === value) ?? options[0]; return (
{isOpen && ( <>
setIsOpen(false)} />
{options.map(option => ( ))}
)}
); } function ToolbarMenu({ label, icon, children, }: { label: string; icon: React.ReactNode; children: React.ReactNode; }) { const [isOpen, setIsOpen] = useState(false); return (
{isOpen && ( <>
setIsOpen(false)} />
{children}
)}
); } const STATUS_GROUP_ORDER: string[] = ['error', 'printing', 'paused', 'finished', 'idle', 'offline']; const STATUS_GROUP_META: Record = { error: { labelKey: 'printers.status.problem', dot: 'bg-status-error' }, printing: { labelKey: 'printers.status.printing', dot: 'bg-bambu-green animate-pulse' }, paused: { labelKey: 'printers.status.paused', dot: 'bg-status-warning' }, finished: { labelKey: 'printers.status.finished', dot: 'bg-blue-400' }, idle: { labelKey: 'printers.status.idle', dot: 'bg-bambu-green' }, offline: { labelKey: 'printers.status.offline', dot: 'bg-gray-400' }, }; /** Classify a printer into one of the UI status buckets. */ function classifyPrinterStatus( status: { connected: boolean; state: string | null; hms_errors?: HMSError[] } | undefined, ): PrinterState { if (!status?.connected) return 'offline'; const hmsErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : []; if (hmsErrors.length > 0) return 'error'; switch (status.state) { case 'RUNNING': return 'printing'; case 'PAUSE': return 'paused'; case 'FINISH': return 'finished'; // FAILED without an active HMS error is the printer's terminal state after // any unsuccessful end — including user-cancellations. Treat the same as // FINISH for grouping/badging purposes; only escalate to "error" when an // HMS code is actually attached (handled by the early-return above). case 'FAILED': return 'finished'; default: return 'idle'; } } /** * Get human-readable status display text for a printer. * Uses stg_cur_name for detailed calibration/preparation stages, * otherwise formats the gcode_state nicely. */ function getStatusDisplay(state: string | null | undefined, stg_cur_name: string | null | undefined): string { // If we have a specific stage name (calibration, heating, etc.), use it if (stg_cur_name) { return stg_cur_name; } // Format the gcode_state nicely switch (state) { case 'RUNNING': return 'Printing'; case 'PAUSE': return 'Paused'; case 'FINISH': return 'Finished'; case 'FAILED': return 'Failed'; case 'IDLE': return 'Idle'; default: return state ? state.charAt(0) + state.slice(1).toLowerCase() : 'Idle'; } } // Map SSDP model codes to display names function mapModelCode(ssdpModel: string | null): string { if (!ssdpModel) return ''; const modelMap: Record = { // H2 Series 'O1D': 'H2D', 'O1E': 'H2D Pro', 'O2D': 'H2D Pro', 'O1C': 'H2C', 'O1C2': 'H2C', 'O1S': 'H2S', // X1 Series 'BL-P001': 'X1C', 'BL-P002': 'X1', 'BL-P003': 'X1E', // X2 Series 'N6': 'X2D', // P Series 'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S', // A1 Series 'N2S': 'A1', 'N1': 'A1 Mini', // Direct matches 'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'X2D': 'X2D', 'P1S': 'P1S', 'P1P': 'P1P', 'P2S': 'P2S', 'A1': 'A1', 'A1 Mini': 'A1 Mini', 'H2D': 'H2D', 'H2D Pro': 'H2D Pro', 'H2C': 'H2C', 'H2S': 'H2S', }; return modelMap[ssdpModel] || ssdpModel; } // ─── AMS Name Hover Card ────────────────────────────────────────────────────── // Wraps the AMS label (e.g. "AMS-A") and shows a popup with: // • User-defined friendly name (editable, protected by printers:update) // • AMS serial number // • AMS firmware version export function AmsNameHoverCard({ ams, printerId, label, amsLabels, canEdit, onSaved, children, }: { ams: import('../api/client').AMSUnit; printerId: number; label: string; // auto-generated label, e.g. "AMS-A" amsLabels?: Record; canEdit: boolean; onSaved: () => void; children: React.ReactNode; }) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); const [editValue, setEditValue] = useState(''); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [isInputFocused, setIsInputFocused] = useState(false); const triggerRef = useRef(null); const cardRef = useRef(null); const timeoutRef = useRef | null>(null); useEffect(() => { if (isVisible) { setEditValue(amsLabels?.[ams.id] ?? ''); setSaveError(null); requestAnimationFrame(() => { if (triggerRef.current && cardRef.current) { const rect = triggerRef.current.getBoundingClientRect(); const spaceAbove = rect.top - 56; const spaceBelow = window.innerHeight - rect.bottom; setPosition(spaceAbove < cardRef.current.offsetHeight + 12 && spaceBelow > spaceAbove ? 'bottom' : 'top'); } }); } }, [isVisible, amsLabels, ams.id]); const handleMouseEnter = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(true), 80); }; const handleMouseLeave = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); if (!isInputFocused) { timeoutRef.current = setTimeout(() => setIsVisible(false), 200); } }; useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []); const handleSave = async () => { if (!canEdit) return; setIsSaving(true); setSaveError(null); try { const trimmed = editValue.trim(); if (trimmed) { await api.saveAmsLabel(printerId, ams.id, trimmed, ams.serial_number); } else { await api.deleteAmsLabel(printerId, ams.id, ams.serial_number); } onSaved(); setIsVisible(false); } catch (err) { setSaveError(err instanceof Error ? err.message : String(err)); } finally { setIsSaving(false); } }; const handleClear = async () => { if (!canEdit) return; setIsSaving(true); setSaveError(null); try { await api.deleteAmsLabel(printerId, ams.id, ams.serial_number); onSaved(); setIsVisible(false); } catch (err) { setSaveError(err instanceof Error ? err.message : String(err)); } finally { setIsSaving(false); } }; return (
{children} {isVisible && (
{/* AMS auto-label */}
{label}
{/* Serial number */}
{t('printers.amsPopup.serialNumber')} {ams.serial_number || '—'}
{/* Firmware version */}
{t('printers.amsPopup.firmwareVersion')} {ams.sw_ver || '—'}
{/* Divider */}
{/* Friendly name editor */}
{t('printers.amsPopup.friendlyName')} canEdit && setEditValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSave()} onFocus={() => setIsInputFocused(true)} onBlur={() => { setIsInputFocused(false); if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(false), 200); }} placeholder={canEdit ? t('printers.amsPopup.friendlyNamePlaceholder') : (amsLabels?.[ams.id] || '—')} disabled={!canEdit} title={!canEdit ? t('printers.amsPopup.noEditPermission') : undefined} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-xs text-white placeholder-bambu-gray/60 focus:outline-none focus:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed" maxLength={100} /> {canEdit && (
{saveError && (

{saveError}

)}
{amsLabels?.[ams.id] && ( )}
)}
)}
); } // AMS drying presets from BambuStudio filament profiles (idle mode temps) // Format: { n3f temp, n3s temp, n3f hours, n3s hours } const DRYING_PRESETS: Record = { 'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 }, 'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 }, 'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 }, 'ABS': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 }, 'ASA': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 }, 'PA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 }, 'PC': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 }, 'PVA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 }, }; function PrinterCard({ printer, hideIfDisconnected, maintenanceInfo, viewMode = 'expanded', cardSize = 2, amsThresholds, spoolmanEnabled = false, linkedSpools, spoolmanUrl, spoolmanSyncMode, onGetAssignment, onUnassignSpool, spoolmanSpools, spoolmanSlotAssignments, spoolmanLoading = false, onUnassignSpoolmanSpool, timeFormat = 'system', cameraViewMode = 'window', onOpenEmbeddedCamera, checkPrinterFirmware = true, dryingPresets = DRYING_PRESETS, requirePlateClear = false, selectionMode = false, isSelected = false, onToggleSelect, }: { printer: Printer; hideIfDisconnected?: boolean; maintenanceInfo?: PrinterMaintenanceInfo; viewMode?: ViewMode; cardSize?: number; amsThresholds?: { humidityGood: number; humidityFair: number; tempGood: number; tempFair: number; }; spoolmanEnabled?: boolean; 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; spoolmanSpools?: InventorySpool[]; spoolmanSlotAssignments?: SpoolmanSlotAssignmentRow[]; spoolmanLoading?: boolean; onUnassignSpoolmanSpool?: (spoolmanSpoolId: number) => void; timeFormat?: 'system' | '12h' | '24h'; cameraViewMode?: 'window' | 'embedded'; onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void; checkPrinterFirmware?: boolean; dryingPresets?: Record; requirePlateClear?: boolean; selectionMode?: boolean; isSelected?: boolean; onToggleSelect?: (id: number) => void; }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const navigate = useNavigate(); const { showToast } = useToast(); const { hasPermission } = useAuth(); const [showMenu, setShowMenu] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleteArchives, setDeleteArchives] = useState(true); const [showEditModal, setShowEditModal] = useState(false); const [showFileManager, setShowFileManager] = useState(false); const [showMQTTDebug, setShowMQTTDebug] = useState(false); const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false); const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false); const [haToggleConfirm, setHaToggleConfirm] = useState(null); const [showHMSModal, setShowHMSModal] = useState(false); const [showStopConfirm, setShowStopConfirm] = useState(false); const [showPauseConfirm, setShowPauseConfirm] = useState(false); const [showSpeedMenu, setShowSpeedMenu] = useState(null); const [showAirductMenu, setShowAirductMenu] = useState(null); const [showBedJogMenu, setShowBedJogMenu] = useState(null); const [bedJogStep, setBedJogStep] = useState(10); const [showNotHomedModal, setShowNotHomedModal] = useState(null); const [showResumeConfirm, setShowResumeConfirm] = useState(false); const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false); const [showUploadForPrint, setShowUploadForPrint] = useState(false); const [showPrinterInfo, setShowPrinterInfo] = useState(false); const [showDiagnostic, setShowDiagnostic] = useState(false); const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []); const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null); // AMS drying popover state: which AMS unit has the popover open const [dryingPopoverAmsId, setDryingPopoverAmsId] = useState(null); const [dryingPopoverModuleType, setDryingPopoverModuleType] = useState('n3f'); const [dryingFilament, setDryingFilament] = useState('PLA'); const [dryingTemp, setDryingTemp] = useState(50); const [dryingDuration, setDryingDuration] = useState(4); const [dryingRotateTray, setDryingRotateTray] = useState(false); const [dryingPopoverPos, setDryingPopoverPos] = useState<{ top: number; left: number } | null>(null); const [isDraggingFile, setIsDraggingFile] = useState(false); const [isDropUploading, setIsDropUploading] = useState(false); const dragCounterRef = useRef(0); const [amsHistoryModal, setAmsHistoryModal] = useState<{ amsId: number; amsLabel: string; mode: 'humidity' | 'temperature'; } | null>(null); const [linkSpoolModal, setLinkSpoolModal] = useState<{ tagUid: string; trayUuid: string; printerId: number; amsId: number; trayId: number; } | null>(null); const [assignSpoolModal, setAssignSpoolModal] = useState<{ printerId: number; amsId: number; trayId: number; trayInfo: { type: string; color: string; location: string; material?: string; profile?: string }; } | null>(null); const [configureSlotModal, setConfigureSlotModal] = useState<{ amsId: number; trayId: number; trayCount: number; trayType?: string; trayColor?: string; traySubBrands?: string; trayInfoIdx?: string; extruderId?: number; caliIdx?: number | null; savedPresetId?: string; } | null>(null); const [showFirmwareModal, setShowFirmwareModal] = useState(false); const [plateCheckResult, setPlateCheckResult] = useState<{ is_empty: boolean; confidence: number; difference_percent: number; message: string; debug_image_url?: string; needs_calibration: boolean; light_warning?: boolean; reference_count?: number; max_references?: number; roi?: { x: number; y: number; w: number; h: number }; } | null>(null); const [isCheckingPlate, setIsCheckingPlate] = useState(false); const [isCalibrating, setIsCalibrating] = useState(false); const [editingRoi, setEditingRoi] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [isSavingRoi, setIsSavingRoi] = useState(false); const [plateCheckLightWasOff, setPlateCheckLightWasOff] = useState(false); const { data: status } = useQuery({ queryKey: ['printerStatus', printer.id], queryFn: () => api.getPrinterStatus(printer.id), refetchInterval: 30000, // Fallback polling, WebSocket handles real-time }); // Check for firmware updates (cached for 5 minutes, can be disabled in settings) const { data: firmwareInfo } = useQuery({ queryKey: ['firmwareUpdate', printer.id], queryFn: () => firmwareApi.checkPrinterUpdate(printer.id), staleTime: 5 * 60 * 1000, refetchInterval: 5 * 60 * 1000, enabled: checkPrinterFirmware && hasPermission('firmware:read'), }); // Collect unique tray_info_idx values for cloud filament info lookup const trayInfoIds = useMemo(() => { const ids = new Set(); if (status?.ams) { for (const ams of status.ams) { for (const tray of ams.tray || []) { if (tray.tray_info_idx) { ids.add(tray.tray_info_idx); } } } } for (const vt of status?.vt_tray ?? []) { if (vt.tray_info_idx) ids.add(vt.tray_info_idx); } if (status?.nozzle_rack) { for (const slot of status.nozzle_rack) { if (slot.filament_id) { ids.add(slot.filament_id); } } } return Array.from(ids); }, [status?.ams, status?.vt_tray, status?.nozzle_rack]); // Collect loaded filament types for queue widget filtering const loadedFilamentTypes = useMemo(() => { const types = new Set(); if (status?.ams) { for (const ams of status.ams) { for (const tray of ams.tray || []) { if (tray.tray_type) types.add(tray.tray_type.toUpperCase()); } } } for (const vt of status?.vt_tray ?? []) { if (vt.tray_type) types.add(vt.tray_type.toUpperCase()); } return types; }, [status?.ams, status?.vt_tray]); // Collect loaded filament type+color pairs for queue widget override matching // Format: "TYPE:rrggbb" (e.g., "PETG:ffffff") — mirrors backend _count_override_color_matches() const loadedFilaments = useMemo(() => { const filaments = new Set(); if (status?.ams) { for (const ams of status.ams) { for (const tray of ams.tray || []) { if (tray.tray_type && tray.tray_color) { const color = tray.tray_color.replace('#', '').toLowerCase().slice(0, 6); filaments.add(`${tray.tray_type.toUpperCase()}:${color}`); } } } } for (const vt of status?.vt_tray ?? []) { if (vt.tray_type && vt.tray_color) { const color = vt.tray_color.replace('#', '').toLowerCase().slice(0, 6); filaments.add(`${vt.tray_type.toUpperCase()}:${color}`); } } return filaments; }, [status?.ams, status?.vt_tray]); // Fetch cloud filament info for tooltips (name includes color, also has K value) const { data: filamentInfo } = useQuery({ queryKey: ['filamentInfo', trayInfoIds], queryFn: () => api.getFilamentInfo(trayInfoIds), enabled: trayInfoIds.length > 0, staleTime: 5 * 60 * 1000, // 5 minutes }); // Fetch slot preset mappings (stores preset name for user-configured slots) const { data: slotPresets } = useQuery({ queryKey: ['slotPresets', printer.id], queryFn: () => api.getSlotPresets(printer.id), staleTime: 2 * 60 * 1000, // 2 minutes }); // Fetch plate list for the archive linked to the active print (#881 follow-up). // Only queried when there's a running print backed by an archive; shared // React Query cache with the Queue / Archives pages keeps it cheap. const activeArchiveId = (status?.state === 'RUNNING' || status?.state === 'PAUSE') ? status?.current_archive_id ?? null : null; const { data: activeArchivePlates } = useQuery({ queryKey: ['archive-plates', activeArchiveId], queryFn: () => api.getArchivePlates(activeArchiveId!), enabled: activeArchiveId != null, staleTime: 5 * 60 * 1000, }); const activePlateLabel = (() => { if (!activeArchivePlates?.is_multi_plate || status?.current_plate_id == null) return null; const plate = activeArchivePlates.plates.find(p => p.index === status.current_plate_id); return plate?.name || t('printers.plateNumber', 'Plate {{number}}', { number: status.current_plate_id }); })(); // Fetch user-defined AMS friendly names from the database const { data: amsLabels, refetch: refetchAmsLabels } = useQuery({ queryKey: ['amsLabels', printer.id], queryFn: () => api.getAmsLabels(printer.id), staleTime: 5 * 60 * 1000, // 5 minutes }); // Cache WiFi signal to prevent it disappearing on updates const [cachedWifiSignal, setCachedWifiSignal] = useState(null); useEffect(() => { if (status?.wifi_signal != null) { setCachedWifiSignal(status.wifi_signal); } }, [status?.wifi_signal]); const wifiSignal = status?.wifi_signal ?? cachedWifiSignal; // Cache connected state to prevent flicker when status briefly becomes undefined const cachedConnected = useRef(undefined); useEffect(() => { if (status?.connected !== undefined) { cachedConnected.current = status.connected; } }, [status?.connected]); const isConnected = status?.connected ?? cachedConnected.current; // Cache ams_extruder_map to prevent L/R indicators bouncing on updates const cachedAmsExtruderMap = useRef>({}); useEffect(() => { if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) { cachedAmsExtruderMap.current = status.ams_extruder_map; } }, [status?.ams_extruder_map]); const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) ? status.ams_extruder_map : cachedAmsExtruderMap.current; // Cache AMS data to prevent it disappearing on idle/offline printers const cachedAmsData = useRef([]); useEffect(() => { if (status?.ams && status.ams.length > 0) { cachedAmsData.current = status.ams; } }, [status?.ams]); const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current; // Cache tray_now to prevent flickering when undefined values come in // Valid tray IDs: 0-253 for AMS, 254 for external spool // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active const cachedTrayNow = useRef(undefined); const currentTrayNow = status?.tray_now; // Update cache: 255 means "no tray" so clear cache; valid values get cached if (currentTrayNow !== undefined && currentTrayNow !== 255) { cachedTrayNow.current = currentTrayNow; } else if (currentTrayNow === 255) { cachedTrayNow.current = undefined; } const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255) ? currentTrayNow : cachedTrayNow.current; // Fetch smart plug for this printer const { data: smartPlug } = useQuery({ queryKey: ['smartPlugByPrinter', printer.id], queryFn: () => api.getSmartPlugByPrinter(printer.id), }); // Fetch script plugs for this printer (for multi-device control) const { data: scriptPlugs } = useQuery({ queryKey: ['scriptPlugsByPrinter', printer.id], queryFn: () => api.getScriptPlugsByPrinter(printer.id), }); // Fetch smart plug status if plug exists (faster refresh for energy monitoring) const { data: plugStatus } = useQuery({ queryKey: ['smartPlugStatus', smartPlug?.id], queryFn: () => smartPlug ? api.getSmartPlugStatus(smartPlug.id) : null, enabled: !!smartPlug, refetchInterval: 10000, // 10 seconds for real-time power display }); // Fetch queue count for this printer const { data: queueItems } = useQuery({ queryKey: ['queue', printer.id, 'pending'], queryFn: () => api.getQueue(printer.id, 'pending'), }); // Filter queue items by filament compatibility (same logic as PrinterQueueWidget) // so the badge only shows on printers that can actually run the queued jobs. // An empty Set means no filaments are loaded — jobs requiring specific types are incompatible. const queueCount = useMemo(() => { if (!queueItems?.length) return 0; return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments).length; }, [queueItems, loadedFilamentTypes, loadedFilaments]); // Fetch currently printing queue item to show who started it (Issue #206) const { data: printingQueueItems } = useQuery({ queryKey: ['queue', printer.id, 'printing'], queryFn: () => api.getQueue(printer.id, 'printing'), enabled: status?.state === 'RUNNING', }); // Fetch reprint user info (for prints started via Reprint, not queue - Issue #206) const { data: reprintUser } = useQuery({ queryKey: ['currentPrintUser', printer.id], queryFn: () => api.getCurrentPrintUser(printer.id), enabled: status?.state === 'RUNNING', }); // Combine both sources: queue item user takes precedence, then reprint user const currentPrintUser = printingQueueItems?.[0]?.created_by_username || reprintUser?.username; // Fetch last completed print for this printer const { data: lastPrints } = useQuery({ queryKey: ['archives', printer.id, 'last'], queryFn: () => api.getArchives(printer.id, 1, 0), enabled: status?.connected && status?.state !== 'RUNNING', }); const lastPrint = lastPrints?.[0]; const isPrintingOrPaused = status?.state === 'RUNNING' || status?.state === 'PAUSE'; const needsPlateClear = requirePlateClear && status?.awaiting_plate_clear === true; const showClearPlateButton = status?.connected && needsPlateClear && !isPrintingOrPaused; const plateStatus = (() => { if (!requirePlateClear || !status?.connected) return null; if (isPrintingOrPaused) { return { label: t('printers.plateStatus.inUse'), className: 'bg-blue-500/20 text-blue-400', }; } if (status.awaiting_plate_clear) { return { label: t('printers.plateStatus.notCleared'), className: 'bg-yellow-500/20 text-yellow-400', }; } return { label: t('printers.plateStatus.cleared'), className: 'bg-status-ok/20 text-status-ok', }; })(); const plateStatusPill = plateStatus ? ( {plateStatus.label} ) : null; // Determine if this card should be hidden (use cached connected state to prevent flicker) const shouldHide = hideIfDisconnected && isConnected === false; const deleteMutation = useMutation({ mutationFn: (options: { deleteArchives: boolean }) => api.deletePrinter(printer.id, options.deleteArchives), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printers'] }); queryClient.invalidateQueries({ queryKey: ['archives'] }); queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] }); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToDelete'), 'error'), }); const connectMutation = useMutation({ mutationFn: () => api.connectPrinter(printer.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, }); const forceRefreshMutation = useMutation({ mutationFn: () => api.refreshPrinterStatus(printer.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); showToast(t('printers.forceRefreshSuccess'), 'success'); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'), }); 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'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] }); }, onError: (error: Error) => { showToast(error.message || t('spoolman.unlinkFailed'), 'error'); }, }); // AMS drying mutations const startDryingMutation = useMutation({ mutationFn: ({ amsId, temp, duration, filament, rotateTray }: { amsId: number; temp: number; duration: number; filament: string; rotateTray: boolean }) => api.startDrying(printer.id, amsId, temp, duration, filament, rotateTray), onSuccess: () => { setDryingPopoverAmsId(null); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'), }); const stopDryingMutation = useMutation({ mutationFn: (amsId: number) => api.stopDrying(printer.id, amsId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'), }); // Smart plug control mutations const powerControlMutation = useMutation({ mutationFn: (action: 'on' | 'off') => smartPlug ? api.controlSmartPlug(smartPlug.id, action) : Promise.reject('No plug'), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['smartPlugStatus', smartPlug?.id] }); }, }); const toggleAutoOffMutation = useMutation({ mutationFn: (enabled: boolean) => smartPlug ? api.updateSmartPlug(smartPlug.id, { auto_off: enabled }) : Promise.reject('No plug'), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', printer.id] }); // Also invalidate the smart-plugs list to keep Settings page in sync queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); }, }); // Run HA entity mutation — scripts use 'on' (trigger), switches use 'toggle' const runScriptMutation = useMutation({ mutationFn: ({ id, action }: { id: number; action: 'on' | 'toggle' }) => api.controlSmartPlug(id, action), onSuccess: () => { showToast(t('printers.toast.scriptTriggered')); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToRunScript'), 'error'), }); // Print control mutations const stopPrintMutation = useMutation({ mutationFn: () => api.stopPrint(printer.id), onSuccess: () => { showToast(t('printers.toast.printStopped')); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToStopPrint'), 'error'), }); const pausePrintMutation = useMutation({ mutationFn: () => api.pausePrint(printer.id), onSuccess: () => { showToast(t('printers.toast.printPaused')); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToPausePrint'), 'error'), }); const resumePrintMutation = useMutation({ mutationFn: () => api.resumePrint(printer.id), onSuccess: () => { showToast(t('printers.toast.printResumed')); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToResumePrint'), 'error'), }); const clearPlateMutation = useMutation({ mutationFn: () => api.clearPlate(printer.id), onSuccess: () => { showToast(t('queue.clearPlateSuccess')); queryClient.setQueryData(['printerStatus', printer.id], (old: PrinterStatus | undefined) => old ? { ...old, awaiting_plate_clear: false } : old ); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); queryClient.invalidateQueries({ queryKey: ['queue', printer.id] }); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'), }); // Chamber light mutation with optimistic update const chamberLightMutation = useMutation({ mutationFn: (on: boolean) => api.setChamberLight(printer.id, on), onMutate: async (on) => { // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] }); // Snapshot the previous value const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]); // Optimistically update queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({ ...old, chamber_light: on, })); return { previousStatus }; }, onSuccess: (_, on) => { showToast(`Chamber light ${on ? 'on' : 'off'}`); }, onError: (error: Error, _, context) => { // Rollback on error if (context?.previousStatus) { queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus); } showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error'); }, }); // Print speed mutation with optimistic update const printSpeedMutation = useMutation({ mutationFn: (mode: number) => api.setPrintSpeed(printer.id, mode), onMutate: async (mode) => { await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] }); const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]); queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({ ...old, speed_level: mode, })); return { previousStatus }; }, onError: (error: Error, _, context) => { if (context?.previousStatus) { queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus); } showToast(error.message || t('printers.toast.failedToSetSpeed'), 'error'); }, }); const airductMutation = useMutation({ mutationFn: (mode: 'cooling' | 'heating') => api.setAirductMode(printer.id, mode), onMutate: async (mode) => { await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] }); const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]); queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({ ...old, airduct_mode: mode === 'cooling' ? 0 : 1, })); return { previousStatus }; }, onError: (error: Error, _, context) => { if (context?.previousStatus) { queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus); } showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'); }, }); const bedJogMutation = useMutation({ mutationFn: ({ distance, force }: { distance: number; force?: boolean }) => api.bedJog(printer.id, distance, force ?? false), onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'), }); const homeAxesMutation = useMutation({ mutationFn: (axes: 'z' | 'xy' | 'all') => api.homeAxes(printer.id, axes), onSuccess: () => { // Flip the session-scoped "warned" flag so the next bed-jog click doesn't re-prompt // the not-homed modal. The flag is the same one "Move anyway" sets; after a successful // auto-home request the printer is (or will shortly be) in a known-homed state, so // prompting again in the same session is noise — #1052 follow-up. try { sessionStorage.setItem(`bambuddy.bedJog.warned.${printer.id}`, '1'); } catch { /* ignore */ } showToast(t('printers.bedJog.homingStarted')); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'), }); // Plate detection setting mutation const plateDetectionMutation = useMutation({ mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printers'] }); showToast(plateDetectionMutation.variables ? t('printers.toast.plateCheckEnabled') : t('printers.toast.plateCheckDisabled')); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUpdateSetting'), 'error'), }); // Query for printable objects (for skip functionality) // Fetch when printing with 2+ objects OR when modal is open const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2; const { data: objectsData } = useQuery({ queryKey: ['printableObjects', printer.id], queryFn: () => api.getPrintableObjects(printer.id), enabled: showSkipObjectsModal || isPrintingWithObjects, refetchInterval: showSkipObjectsModal ? 5000 : (isPrintingWithObjects ? 30000 : false), // 5s when modal open, 30s otherwise }); // State for tracking which AMS slot is being refreshed const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null); // Track if we've seen the printer enter "busy" state (ams_status_main !== 0) const seenBusyStateRef = useRef(false); // Fallback timeout ref const refreshTimeoutRef = useRef | null>(null); // Minimum display time passed const minTimePassedRef = useRef(false); // AMS slot refresh mutation const refreshAmsSlotMutation = useMutation({ mutationFn: ({ amsId, slotId }: { amsId: number; slotId: number }) => api.refreshAmsSlot(printer.id, amsId, slotId), onMutate: ({ amsId, slotId }) => { // Clear any existing timeout if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } // Reset state seenBusyStateRef.current = false; minTimePassedRef.current = false; setRefreshingSlot({ amsId, slotId }); // Minimum display time (2 seconds) setTimeout(() => { minTimePassedRef.current = true; }, 2000); // Fallback timeout (30 seconds max) refreshTimeoutRef.current = setTimeout(() => { setRefreshingSlot(null); }, 30000); }, onSuccess: (data) => { showToast(data.message || t('printers.toast.rfidRereadInitiated')); }, onError: (error: Error) => { showToast(error.message || t('printers.toast.failedToRereadRfid'), 'error'); if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } setRefreshingSlot(null); }, }); // AMS load/unload mutations (#891) const loadAmsTrayMutation = useMutation({ mutationFn: ({ trayId }: { trayId: number }) => api.loadAmsTray(printer.id, trayId), onSuccess: (data) => { showToast(data.message || t('printers.toast.loadInitiated')); }, onError: (error: Error) => { showToast(error.message || t('printers.toast.failedToLoad'), 'error'); }, }); const unloadAmsMutation = useMutation({ mutationFn: () => api.unloadAms(printer.id), onSuccess: (data) => { showToast(data.message || t('printers.toast.unloadInitiated')); }, onError: (error: Error) => { showToast(error.message || t('printers.toast.failedToUnload'), 'error'); }, }); // Plate references state const [plateReferences, setPlateReferences] = useState<{ references: Array<{ index: number; label: string; timestamp: string; has_image: boolean; thumbnail_url: string }>; max_references: number; } | null>(null); const [editingRefLabel, setEditingRefLabel] = useState<{ index: number; label: string } | null>(null); // Fetch plate references const fetchPlateReferences = async () => { try { const data = await api.getPlateReferences(printer.id); setPlateReferences(data); } catch { // Ignore errors - references will show as empty } }; // Toggle plate detection enabled/disabled const handleTogglePlateDetection = () => { plateDetectionMutation.mutate(!printer.plate_detection_enabled); }; // Open plate detection management modal (for calibration/references) const handleOpenPlateManagement = async () => { setIsCheckingPlate(true); setPlateCheckResult(null); // Auto-turn on light if it's off const lightWasOff = status?.chamber_light === false; setPlateCheckLightWasOff(lightWasOff); if (lightWasOff) { await api.setChamberLight(printer.id, true); // Wait for light to physically turn on and camera to adjust exposure // (MQTT command is async, light takes ~1s to turn on, camera needs time to adjust) await new Promise(resolve => setTimeout(resolve, 2500)); } try { const result = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); setPlateCheckResult(result); fetchPlateReferences(); } catch (error) { showToast(error instanceof Error ? error.message : t('printers.toast.failedToCheckPlate'), 'error'); // Restore light if check failed if (lightWasOff) { await api.setChamberLight(printer.id, false); setPlateCheckLightWasOff(false); } } finally { setIsCheckingPlate(false); } }; // Close plate check modal and restore light state const closePlateCheckModal = useCallback(async () => { setPlateCheckResult(null); // Restore light to original state if we turned it on if (plateCheckLightWasOff) { await api.setChamberLight(printer.id, false); setPlateCheckLightWasOff(false); } }, [plateCheckLightWasOff, printer.id]); // Calibrate plate detection handler const handleCalibratePlate = async (label?: string) => { setIsCalibrating(true); try { const result = await api.calibratePlateDetection(printer.id, { label }); if (result.success) { showToast(result.message || t('printers.toast.calibrationSaved'), 'success'); // Refresh references and re-check fetchPlateReferences(); const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); setPlateCheckResult(checkResult); } else { showToast(result.message || t('printers.toast.calibrationFailed'), 'error'); } } catch (error) { showToast(error instanceof Error ? error.message : t('printers.toast.calibrationFailed'), 'error'); } finally { setIsCalibrating(false); } }; // Update reference label const handleUpdateRefLabel = async (index: number, label: string) => { try { await api.updatePlateReferenceLabel(printer.id, index, label); setEditingRefLabel(null); fetchPlateReferences(); } catch (error) { showToast(error instanceof Error ? error.message : t('printers.toast.failedToUpdateLabel'), 'error'); } }; // Delete reference const handleDeleteRef = async (index: number) => { try { await api.deletePlateReference(printer.id, index); showToast(t('printers.toast.referenceDeleted'), 'success'); fetchPlateReferences(); // Re-check to update counts const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); setPlateCheckResult(checkResult); } catch (error) { showToast(error instanceof Error ? error.message : t('printers.toast.failedToDeleteReference'), 'error'); } }; // Save ROI settings const handleSaveRoi = async () => { if (!editingRoi) return; setIsSavingRoi(true); try { await api.updatePrinter(printer.id, { plate_detection_roi: editingRoi }); showToast(t('printers.toast.detectionAreaSaved'), 'success'); setEditingRoi(null); // Re-check to see new ROI in action const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); setPlateCheckResult(checkResult); } catch (error) { showToast(error instanceof Error ? error.message : t('printers.toast.failedToSaveDetectionArea'), 'error'); } finally { setIsSavingRoi(false); } }; // Close plate check modal on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && plateCheckResult) { closePlateCheckModal(); } }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [plateCheckResult, closePlateCheckModal]); // Watch ams_status_main to detect when RFID read completes // ams_status_main: 0=idle, 2=rfid_identifying const deferredClearRef = useRef | null>(null); useEffect(() => { if (!refreshingSlot) return; const amsStatus = status?.ams_status_main ?? 0; // Track when we see non-idle state (printer is working) if (amsStatus !== 0) { seenBusyStateRef.current = true; // Cancel any deferred clear since we're back to busy if (deferredClearRef.current) { clearTimeout(deferredClearRef.current); deferredClearRef.current = null; } } // When we've seen busy and now idle, clear (with min time check) if (seenBusyStateRef.current && amsStatus === 0) { if (minTimePassedRef.current) { // Min time passed - clear now if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } setRefreshingSlot(null); } else { // Schedule clear after min time (2 seconds from start) if (!deferredClearRef.current) { deferredClearRef.current = setTimeout(() => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } setRefreshingSlot(null); }, 2000); } } } return () => { if (deferredClearRef.current) { clearTimeout(deferredClearRef.current); } }; }, [status?.ams_status_main, refreshingSlot]); // State for AMS slot menu const [amsSlotMenu, setAmsSlotMenu] = useState<{ amsId: number; slotId: number } | null>(null); if (shouldHide) { return null; } // Size-based styling helpers const getImageSize = () => { switch (cardSize) { case 1: return 'w-10 h-10'; case 2: return 'w-14 h-14'; case 3: return 'w-16 h-16'; case 4: return 'w-20 h-20'; default: return 'w-14 h-14'; } }; const getTitleSize = () => { switch (cardSize) { case 1: return 'text-base truncate'; case 2: return 'text-lg'; case 3: return 'text-xl'; case 4: return 'text-2xl'; default: return 'text-lg'; } }; const getSpacing = () => { switch (cardSize) { case 1: return 'mb-2'; case 2: return 'mb-4'; case 3: return 'mb-5'; case 4: return 'mb-6'; default: return 'mb-4'; } }; const canDrop = isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && hasPermission('printers:control'); const handleCardDragEnter = (e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current++; if (dragCounterRef.current === 1) setIsDraggingFile(true); }; const handleCardDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = canDrop ? 'copy' : 'none'; }; const handleCardDragLeave = (e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDraggingFile(false); }; const handleCardDrop = async (e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDraggingFile(false); if (!canDrop) return; const droppedFiles = Array.from(e.dataTransfer.files); const file = droppedFiles[0]; if (!file) return; // Only accept sliced/printable files (.gcode, .gcode.3mf, etc.) const lower = file.name.toLowerCase(); if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) { showToast(t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed'), 'error'); return; } setIsDropUploading(true); try { const result = await api.uploadLibraryFile(file, null); // Check printer compatibility if sliced_for_model is available in metadata const slicedFor = (result.metadata as Record)?.sliced_for_model as string | undefined; const printerModel = mapModelCode(printer.model); if (slicedFor && printerModel && slicedFor.toLowerCase() !== printerModel.toLowerCase()) { await api.deleteLibraryFile(result.id).catch(() => {}); showToast( t('printers.incompatibleFile', 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}', { slicedFor, printerModel }), 'error' ); return; } setPrintAfterUpload({ id: result.id, filename: result.filename }); } catch { showToast(t('common.uploadFailed', 'Upload failed'), 'error'); } finally { setIsDropUploading(false); } }; return ( {/* Selection mode click overlay — captures all clicks, preventing nested interactions */} {selectionMode && (
{ e.stopPropagation(); onToggleSelect?.(printer.id); }} > {isSelected ? ( ) : ( )}
)} {/* Drop zone overlay */} {(isDraggingFile || isDropUploading) && (
{isDropUploading ? ( <>

{t('common.uploading', 'Uploading...')}

) : canDrop ? ( <>

{t('printers.dropToPrint', 'Drop to print')}

) : ( <>

{t('printers.cannotPrint', 'Printer busy')}

)}
)} = 3 ? 'p-5' : ''}> {/* Header */}
{/* Top row: Image, Name, Menu */}
{/* Printer Model Image */} {printer.model

{printer.name}

{/* Connection indicator dot for compact mode */} {viewMode === 'compact' && (() => { const hmsErrors = status?.connected && status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : []; const hasSevere = hmsErrors.some(e => e.severity <= 2); const hasWarning = hmsErrors.length > 0; const pipColor = !status?.connected ? 'bg-status-error' : hasSevere ? 'bg-status-error' : hasWarning ? 'bg-status-warning' : 'bg-status-ok'; const pipTitle = !status?.connected ? t('printers.connection.offline') : hasWarning ? `${hmsErrors.length} HMS ${hmsErrors.length === 1 ? 'error' : 'errors'}` : t('printers.connection.connected'); return (
); })()}

{printer.model || 'Unknown Model'} {/* Nozzle Info - only in expanded */} {viewMode === 'expanded' && status?.nozzles && status.nozzles[0]?.nozzle_diameter && ( • {status.nozzles[0].nozzle_diameter}mm )} {viewMode === 'expanded' && maintenanceInfo && maintenanceInfo.total_print_hours > 0 && ( {Math.round(maintenanceInfo.total_print_hours)}h )}

{/* Menu button */}
{showMenu && (
)}
{/* Badges row - only in expanded mode */} {viewMode === 'expanded' && (
{/* Connection status badge */} {status?.connected ? ( ) : ( )} {status?.connected ? t('printers.connection.connected') : t('printers.connection.offline')} {/* Run connection diagnostic — offered when the printer is offline */} {!status?.connected && ( )} {/* Network connection indicator */} {status?.connected && status?.wired_network && ( {t('printers.connection.ethernet', 'Ethernet')} )} {/* WiFi signal indicator */} {status?.connected && !status?.wired_network && wifiSignal != null && ( = -50 ? 'bg-status-ok/20 text-status-ok' : wifiSignal >= -60 ? 'bg-status-ok/20 text-status-ok' : wifiSignal >= -70 ? 'bg-status-warning/20 text-status-warning' : wifiSignal >= -80 ? 'bg-orange-500/20 text-orange-600' : 'bg-status-error/20 text-status-error' }`} title={`WiFi: ${wifiSignal} dBm - ${t(getWifiStrength(wifiSignal).labelKey)}`} > {wifiSignal}dBm )} {/* HMS Status Indicator */} {status?.connected && (() => { const knownErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : []; return ( ); })()} {/* Maintenance Status Indicator */} {maintenanceInfo && ( )} {/* Queue Count Badge */} {queueCount > 0 && ( )} {/* Firmware Version Badge */} {checkPrinterFirmware && firmwareInfo?.current_version && firmwareInfo?.latest_version ? ( ) : status?.firmware_version ? ( {status.firmware_version} ) : null} {/* Enclosure Door Badge (X1/X2D/P1S/P2S/H2*) */} {status?.connected && ['X1C', 'X1', 'X1E', 'X2D', 'P1S', 'P1P', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '') && ( {status.door_open ? : } )}
)}
{/* Delete Confirmation */} {showDeleteConfirm && (

{t('printers.confirm.deleteTitle')}

{t('printers.confirm.deleteMessage', { name: printer.name })}

)} {/* Status */} {status?.connected && ( <> {/* Compact: Simple status bar */} {viewMode === 'compact' ? (
{(status.state === 'RUNNING' || status.state === 'PAUSE') ? (
{Math.round(status.progress || 0)}% {plateStatusPill}
) : (

{getStatusDisplay(status.state, status.stg_cur_name)}

{plateStatusPill}
{showClearPlateButton && ( )}
)}
) : ( /* Expanded: Full status section */ <> {/* Current Print or Idle Placeholder */}
{/* Skip Objects button - top right corner, always visible */}
{/* Cover Image */} {/* Print Info */}
{status.current_print && (status.state === 'RUNNING' || status.state === 'PAUSE') ? ( <>

{getStatusDisplay(status.state, status.stg_cur_name)}

{plateStatusPill}

{formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel)}

{Math.round(status.progress || 0)}%
{status.remaining_time != null && status.remaining_time > 0 && ( <> {formatDuration(status.remaining_time * 60)} ETA {formatETA(status.remaining_time, timeFormat, t)} )} {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && ( {status.layer_num}/{status.total_layers} )} {currentPrintUser && ( {currentPrintUser} )}
) : ( <>

{t('printers.sort.status')}

{getStatusDisplay(status.state, status.stg_cur_name)}

{plateStatusPill}
{lastPrint ? (

Last: {lastPrint.print_name || lastPrint.filename} {lastPrint.completed_at && ( • {formatDateOnly(lastPrint.completed_at, { month: 'short', day: 'numeric' })} )}

) : (

{t('printers.readyToPrint')}

)} )}
{/* Queue Widget - always visible when there are pending items */} )} {/* Temperatures */} {status.temperatures && viewMode === 'expanded' && (() => { // Use actual heater states from MQTT stream const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false; const bedHeating = status.temperatures.bed_heating || false; const chamberHeating = status.temperatures.chamber_heating || false; const isDualNozzle = printer.nozzle_count === 2 || status.temperatures.nozzle_2 !== undefined; // active_extruder: 0=right, 1=left const activeNozzle = status.active_extruder === 1 ? 'L' : 'R'; // Extended nozzle data from nozzle_rack (H2 series: wear, serial, max_temp, etc.) // nozzle_rack id 0 = extruder 0 = RIGHT, id 1 = extruder 1 = LEFT const leftNozzleSlot = status.nozzle_rack?.find(s => s.id === 1); const rightNozzleSlot = status.nozzle_rack?.find(s => s.id === 0); // Single-nozzle models (H2D, H2C): use the primary nozzle (id 0) const singleNozzleSlot = rightNozzleSlot || leftNozzleSlot; return (
{/* Nozzle temp - combined for dual nozzle */}
{status.temperatures.nozzle_2 !== undefined ? ( <>

L / R

{Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°

) : singleNozzleSlot ? (

{t('printers.temperatures.nozzle')}

{Math.round(status.temperatures.nozzle || 0)}°C

) : ( <>

{t('printers.temperatures.nozzle')}

{Math.round(status.temperatures.nozzle || 0)}°C

)}

{t('printers.temperatures.bed')}

{Math.round(status.temperatures.bed || 0)}°C

{status.temperatures.chamber !== undefined && (

{t('printers.temperatures.chamber')}

{Math.round(status.temperatures.chamber || 0)}°C

)} {/* Active nozzle indicator for dual-nozzle printers */} {isDualNozzle && (
L{leftNozzleSlot?.nozzle_diameter ? ` ${leftNozzleSlot.nozzle_diameter}` : ''} · R{rightNozzleSlot?.nozzle_diameter ? ` ${rightNozzleSlot.nozzle_diameter}` : ''}

{t('printers.temperatures.nozzle')}

)} {/* H2C nozzle rack (tool-changer dock) — only show when rack nozzles exist (IDs >= 2) */} {status.nozzle_rack && status.nozzle_rack.some(s => s.id >= 2) && ( )}
); })()} {viewMode === 'expanded' && showClearPlateButton && ( )} {/* Controls - Fans + Print Buttons */} {viewMode === 'expanded' && (() => { // Determine print state for control buttons const isRunning = status.state === 'RUNNING'; const isPaused = status.state === 'PAUSE'; const isPrinting = isRunning || isPaused; const isControlBusy = stopPrintMutation.isPending || pausePrintMutation.isPending || resumePrintMutation.isPending; // Fan data const partFan = status.cooling_fan_speed; const auxFan = status.big_fan1_speed; const chamberFan = status.big_fan2_speed; return (
{/* Section Header */}
{t('printers.controls')}
{/* Left: Fan Status - always visible, dynamic coloring */}
{/* Part Cooling Fan */}
0 ? 'bg-cyan-500/10' : 'bg-bambu-dark'}`} title={t('printers.fans.partCooling')} > 0 ? 'text-cyan-400' : 'text-bambu-gray/50'}`} /> 0 ? 'text-cyan-400' : 'text-bambu-gray/50'}`}> {partFan ?? 0}%
{/* Auxiliary Fan */}
0 ? 'bg-blue-500/10' : 'bg-bambu-dark'}`} title={t('printers.fans.auxiliary')} > 0 ? 'text-blue-400' : 'text-bambu-gray/50'}`} /> 0 ? 'text-blue-400' : 'text-bambu-gray/50'}`}> {auxFan ?? 0}%
{/* Chamber Fan */}
0 ? 'bg-green-500/10' : 'bg-bambu-dark'}`} title={t('printers.fans.chamber')} > 0 ? 'text-green-400' : 'text-bambu-gray/50'}`} /> 0 ? 'text-green-400' : 'text-bambu-gray/50'}`}> {chamberFan ?? 0}%
{/* Separator */}
{/* Airduct Mode (P2S / X2D / H2*) */} {(['P2S', 'X2D', 'H2D', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => { const isHeating = status.airduct_mode === 1; const Icon = isHeating ? Flame : Snowflake; const color = isHeating ? 'text-orange-400' : 'text-sky-400'; const bg = isHeating ? 'bg-orange-500/10 hover:bg-orange-500/20' : 'bg-sky-500/10 hover:bg-sky-500/20'; return (
{showAirductMenu === printer.id && ( <>
setShowAirductMenu(null)} />
{([ { mode: 'cooling', label: t('printers.airduct.cooling'), modeId: 0 }, { mode: 'heating', label: t('printers.airduct.heating'), modeId: 1 }, ] as const).map(({ mode, label, modeId }) => ( ))}
)}
); })()} {/* Print Speed */} {(() => { const speedLabels: Record = { 1: '50%', 2: '100%', 3: '124%', 4: '166%' }; const speedPct = speedLabels[status.speed_level] || '100%'; return (
{showSpeedMenu === printer.id && ( <>
setShowSpeedMenu(null)} />
{([ { mode: 1, label: t('printers.speed.silent') }, { mode: 2, label: t('printers.speed.standard') }, { mode: 3, label: t('printers.speed.sport') }, { mode: 4, label: t('printers.speed.ludicrous') }, ] as const).map(({ mode, label }) => ( ))}
)}
); })()} {/* Separator */}
{/* Bed Jog (Z-axis) — compact badge, popover holds the actual controls */} {(() => { const canControl = hasPermission('printers:control'); const disabled = isPrinting || !canControl; const bambuIsPlateBelow = true; // positive Z moves plate away from nozzle const requestJog = (direction: 1 | -1) => { const signed = direction * bedJogStep * (bambuIsPlateBelow ? 1 : -1); const warnedKey = `bambuddy.bedJog.warned.${printer.id}`; const warned = (() => { try { return sessionStorage.getItem(warnedKey) === '1'; } catch { return false; } })(); setShowBedJogMenu(null); if (warned) { bedJogMutation.mutate({ distance: signed, force: true }); } else { setShowNotHomedModal({ distance: signed }); } }; return (
{showBedJogMenu === printer.id && ( <>
setShowBedJogMenu(null)} />
{t('printers.bedJog.step')}
{[1, 10, 50].map((step) => ( ))}
)}
); })()}
{/* Right: Print Control Buttons */}
{/* Stop button */} {/* Pause/Resume button */}
); })()} {/* AMS Units - 2-Column Grid Layout */} {(amsData?.length > 0 || status.vt_tray.length > 0) && viewMode === 'expanded' && (() => { // Separate regular AMS (4-tray) from HT AMS (1-tray) const regularAms = amsData.filter(ams => ams.tray.length > 1); const htAms = amsData.filter(ams => ams.tray.length === 1); const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined; return (
{/* Section Header */}
{t('printers.filaments')}
{/* AMS Content */}
{/* Row 1-2: Regular AMS (4-tray) in 2-column grid */} {regularAms.length > 0 && (
{regularAms.map((ams) => { const mappedExtruderId = amsExtruderMap[String(ams.id)]; const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id; const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId; const isLeftNozzle = extruderId === 1; const isRightNozzle = extruderId === 0; return (
{/* Header: Label + Stats (no icon) */}
{/* AMS name — hover to see serial, firmware, and edit friendly name */} {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)} {isDualNozzle && (isLeftNozzle || isRightNozzle) && ( )}
{(ams.humidity != null || ams.temp != null) && (
{ams.humidity != null && ( setAmsHistoryModal({ amsId: ams.id, amsLabel: getAmsLabel(ams.id, ams.tray.length), mode: 'humidity', })} compact /> )} {ams.temp != null && ( setAmsHistoryModal({ amsId: ams.id, amsLabel: getAmsLabel(ams.id, ams.tray.length), mode: 'temperature', })} compact /> )} {/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s) */} {status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && ( )}
)}
{/* Drying status bar */} {ams.dry_time > 0 && (
{t('printers.drying.active')} {t('printers.drying.timeRemaining', { time: ams.dry_time >= 60 ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m` : `${ams.dry_time}m` })}
)} {/* Slots grid: 4 columns - always render 4 slots */}
{[0, 1, 2, 3].map((slotIdx) => { // Find tray data for this slot (may be undefined if data incomplete) // Use array index if available, as tray.id may not always be set const tray = ams.tray[slotIdx] || ams.tray.find(t => t.id === slotIdx); const hasFillLevel = tray?.tray_type && tray.remain >= 0; const isEmpty = !tray?.tray_type; const emptyKind = getEmptySlotKind(tray); // Check if this is the currently loaded tray // Global tray ID = ams.id * 4 + slot index (for standard AMS) const globalTrayId = ams.id * 4 + slotIdx; const isActive = effectiveTrayNow === globalTrayId; // Get cloud preset info if available 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]; // Fill level fallback chain: Spoolman → Inventory → AMS remain 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); // Slot-assigned-only spool fill (no tag link required) const slotAssignmentForFill = spoolmanEnabled && !spoolmanLoading ? spoolmanSlotAssignments?.find(a => a.printer_id === printer.id && a.ams_id === ams.id && a.tray_id === slotIdx) : undefined; const slotSpoolForFill = slotAssignmentForFill ? spoolmanSpools?.find(s => s.id === slotAssignmentForFill.spoolman_spool_id) : undefined; const slotSpoolFill = (slotSpoolForFill && (slotSpoolForFill.label_weight ?? 0) > 0) ? Math.round(Math.max(0, (slotSpoolForFill.label_weight ?? 0) - slotSpoolForFill.weight_used) / (slotSpoolForFill.label_weight ?? 1) * 100) : null; const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx); const inventoryFill = (() => { const sp = inventoryAssignment?.spool; if (sp && sp.label_weight > 0 && sp.weight_used != null) { return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100); } return null; })(); // If inventory says 0% but AMS reports positive remain, prefer AMS // (inventory weight_used may be stale or over-counted — #676) const resolvedInventoryFill = (inventoryFill === 0 && hasFillLevel && tray.remain > 0) ? null : inventoryFill; const effectiveFill = spoolmanFill ?? slotSpoolFill ?? resolvedInventoryFill ?? (hasFillLevel ? tray.remain : null); const fillSource = (spoolmanFill !== null || slotSpoolFill !== null) ? 'spoolman' as const : resolvedInventoryFill !== null ? 'inventory' as const : hasFillLevel ? 'ams' as const : undefined; // Build filament data for hover card const filamentData = tray?.tray_type ? { vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', // Spoolman spool name wins over cloud lookup so a slot bound to // a Spoolman spool shows that spool's preset name (e.g. "Devil // Design PLA") instead of whatever the printer's filament_id // resolves to in the cloud catalog (often "Generic PLA" for // P-prefix local presets). Spoolman's filament.name is just the // material+subtype ("PLA Basic"); prepend the spool's brand so // the hover card shows "Devil Design PLA Basic" rather than the // vendor-less form. Strip the "@..." suffix that // BambuStudio appends to user-preset names. profile: slotPreset?.preset_name || (slotSpoolForFill ? [slotSpoolForFill.brand, slotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || slotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || inventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type, colorName: getColorName(tray.tray_color || ''), colorHex: tray.tray_color || null, kFactor: formatKValue(tray.k), fillLevel: effectiveFill, trayUuid: tray.tray_uuid || null, tagUid: tray.tag_uid || null, fillSource, } : null; // Check if this specific slot is being refreshed const isRefreshing = refreshingSlot?.amsId === ams.id && refreshingSlot?.slotId === slotIdx; // Slot visual content (goes inside hover card) const slotVisual = (
{/* Filament color circle with 1-based slot number centered inside */}
{tray?.tray_type || t('ams.slotEmpty')}
{/* Fill bar */}
{effectiveFill !== null && effectiveFill >= 0 && !isEmpty && tray && (
)}
); // Wrapper with menu button, dropdown, and loading overlay (outside hover card) return (
{/* Loading overlay during RFID re-read */} {isRefreshing && (
)} {/* Menu button - appears on hover, hidden when printer busy */} {status?.state !== 'RUNNING' && ( )} {/* Dropdown menu */} {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx && (
)} {/* Hover card wraps only the visual content */} {filamentData ? ( { const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx)).toUpperCase(); setLinkSpoolModal({ 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={(() => { if (spoolmanEnabled) { if (spoolmanLoading) return undefined; const slotAssignment = slotAssignmentForFill; const spoolmanSpool = slotSpoolForFill; return { assignedSpool: spoolmanSpool ? { id: spoolmanSpool.id, material: spoolmanSpool.material, brand: spoolmanSpool.brand ?? null, color_name: spoolmanSpool.color_name ?? null, remainingWeightGrams: spoolmanSpool.label_weight ? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used)) : undefined, } : null, onAssignSpool: () => setAssignSpoolModal({ printerId: printer.id, amsId: ams.id, trayId: slotIdx, trayInfo: { type: tray?.tray_type || filamentData.profile, material: tray?.tray_type ?? undefined, profile: filamentData.profile, color: filamentData.colorHex || '', location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`, }, }), onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? () => onUnassignSpoolmanSpool?.(spoolmanSpool.id) : undefined, isAssigned: !!slotAssignment || isBambuLabSpool(tray), }; } const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx); return { assignedSpool: assignment?.spool ? { id: assignment.spool.id, material: assignment.spool.material, brand: assignment.spool.brand, color_name: assignment.spool.color_name, remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)), } : null, onAssignSpool: () => setAssignSpoolModal({ printerId: printer.id, amsId: ams.id, trayId: slotIdx, trayInfo: { type: tray?.tray_type || filamentData.profile, material: tray?.tray_type ?? undefined, profile: filamentData.profile, color: filamentData.colorHex || '', location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`, }, }), onUnassignSpool: (assignment && !isBambuLabSpool(tray)) ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined, isAssigned: !!assignment || isBambuLabSpool(tray), }; })()} configureSlot={{ enabled: hasPermission('printers:control'), onConfigure: () => setConfigureSlotModal({ amsId: ams.id, trayId: slotIdx, trayCount: ams.tray.length, trayType: tray?.tray_type || undefined, trayColor: tray?.tray_color || undefined, traySubBrands: tray?.tray_sub_brands || undefined, trayInfoIdx: tray?.tray_info_idx || undefined, extruderId: mappedExtruderId, caliIdx: tray?.cali_idx, savedPresetId: slotPreset?.preset_id, }), }} > {slotVisual} ) : ( setConfigureSlotModal({ amsId: ams.id, trayId: slotIdx, trayCount: ams.tray.length, extruderId: mappedExtruderId, }), }} onAssignSpool={() => setAssignSpoolModal({ printerId: printer.id, amsId: ams.id, trayId: slotIdx, trayInfo: { type: '', material: undefined, profile: '', color: '', location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`, }, })} > {slotVisual} )}
); })}
); })}
)} {/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */} {(htAms.length > 0 || status.vt_tray.length > 0) && (
{/* HT AMS units - name/badge top, slot left, stats right */} {htAms.map((ams) => { const mappedExtruderId = amsExtruderMap[String(ams.id)]; const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id; const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId; const isLeftNozzle = extruderId === 1; const isRightNozzle = extruderId === 0; const tray = ams.tray[0]; const hasFillLevel = tray?.tray_type && tray.remain >= 0; const isEmpty = !tray?.tray_type; const emptyKind = getEmptySlotKind(tray); // Check if this is the currently loaded tray const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false); const isActive = effectiveTrayNow === globalTrayId; // Get cloud preset info if available 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 || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId))?.toUpperCase(); const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined; const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool); const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htSlotId); const htInventoryFill = (() => { const sp = htInventoryAssignment?.spool; if (sp && sp.label_weight > 0 && sp.weight_used != null) { return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100); } return null; })(); // If inventory says 0% but AMS reports positive remain, prefer AMS (#676) const htResolvedInventoryFill = (htInventoryFill === 0 && hasFillLevel && tray.remain > 0) ? null : htInventoryFill; // Slot-assigned-only fill (when spool has no NFC tag but is slot-assigned) const htSlotAssignmentForFill = spoolmanEnabled && !spoolmanLoading ? spoolmanSlotAssignments?.find(a => a.printer_id === printer.id && a.ams_id === ams.id && a.tray_id === htSlotId) : undefined; const htSlotSpoolForFill = htSlotAssignmentForFill ? spoolmanSpools?.find(s => s.id === htSlotAssignmentForFill.spoolman_spool_id) : undefined; const htSlotSpoolFill = (htSlotSpoolForFill && (htSlotSpoolForFill.label_weight ?? 0) > 0) ? Math.round(Math.max(0, (htSlotSpoolForFill.label_weight ?? 0) - htSlotSpoolForFill.weight_used) / (htSlotSpoolForFill.label_weight ?? 1) * 100) : null; const htEffectiveFill = htSpoolmanFill ?? htSlotSpoolFill ?? htResolvedInventoryFill ?? (hasFillLevel ? tray.remain : null); const htFillSource = (htSpoolmanFill !== null || htSlotSpoolFill !== null) ? 'spoolman' as const : htResolvedInventoryFill !== null ? 'inventory' as const : hasFillLevel ? 'ams' as const : undefined; // Build filament data for hover card const filamentData = tray?.tray_type ? { vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', profile: slotPreset?.preset_name || (htSlotSpoolForFill ? [htSlotSpoolForFill.brand, htSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || htSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || htInventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type, colorName: getColorName(tray.tray_color || ''), colorHex: tray.tray_color || null, kFactor: formatKValue(tray.k), fillLevel: htEffectiveFill, trayUuid: tray.tray_uuid || null, tagUid: tray.tag_uid || null, fillSource: htFillSource, } : null; // Check if this specific slot is being refreshed const isHtRefreshing = refreshingSlot?.amsId === ams.id && refreshingSlot?.slotId === htSlotId; // Slot visual content (goes inside hover card) const slotVisual = (
{/* Filament color circle with 1-based slot number centered inside */}
{tray?.tray_type || t('ams.slotEmpty')}
{/* Fill bar */}
{htEffectiveFill !== null && htEffectiveFill >= 0 && !isEmpty && (
)}
); return (
{/* Row 1: Label + Nozzle + Drying */}
{/* AMS name — hover to see serial, firmware, and edit friendly name */} {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)} {isDualNozzle && (isLeftNozzle || isRightNozzle) && ( )} {/* Drying button for HT AMS */} {status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
)}
{/* HT AMS drying status bar */} {ams.dry_time > 0 && (
{ams.dry_time >= 60 ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m` : `${ams.dry_time}m`}
)} {/* Row 2: Slot (left) + Stats (right stacked) */}
{/* Slot wrapper with menu button, dropdown, and loading overlay */}
{/* Loading overlay during RFID re-read */} {isHtRefreshing && (
)} {/* Menu button - appears on hover, hidden when printer busy */} {status?.state !== 'RUNNING' && ( )} {/* Dropdown menu */} {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId && (
)} {/* Hover card wraps only the visual content */} {filamentData ? ( { const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId)).toUpperCase(); setLinkSpoolModal({ 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={(() => { if (spoolmanEnabled) { if (spoolmanLoading) return undefined; const slotAssignment = htSlotAssignmentForFill; const spoolmanSpool = htSlotSpoolForFill; return { assignedSpool: spoolmanSpool ? { id: spoolmanSpool.id, material: spoolmanSpool.material, brand: spoolmanSpool.brand ?? null, color_name: spoolmanSpool.color_name ?? null, remainingWeightGrams: spoolmanSpool.label_weight ? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used)) : undefined, } : null, onAssignSpool: () => setAssignSpoolModal({ printerId: printer.id, amsId: ams.id, trayId: htSlotId, trayInfo: { type: tray?.tray_type || filamentData.profile, material: tray?.tray_type ?? undefined, profile: filamentData.profile, color: filamentData.colorHex || '', location: getAmsLabel(ams.id, ams.tray.length), }, }), onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? () => onUnassignSpoolmanSpool?.(spoolmanSpool.id) : undefined, isAssigned: !!slotAssignment || isBambuLabSpool(tray), }; } const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId); return { assignedSpool: assignment?.spool ? { id: assignment.spool.id, material: assignment.spool.material, brand: assignment.spool.brand, color_name: assignment.spool.color_name, remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)), } : null, onAssignSpool: () => setAssignSpoolModal({ printerId: printer.id, amsId: ams.id, trayId: htSlotId, trayInfo: { type: tray?.tray_type || filamentData.profile, material: tray?.tray_type ?? undefined, profile: filamentData.profile, color: filamentData.colorHex || '', location: getAmsLabel(ams.id, ams.tray.length), }, }), onUnassignSpool: (assignment && !isBambuLabSpool(tray)) ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined, isAssigned: !!assignment || isBambuLabSpool(tray), }; })()} configureSlot={{ enabled: hasPermission('printers:control'), onConfigure: () => setConfigureSlotModal({ amsId: ams.id, trayId: htSlotId, trayCount: ams.tray.length, trayType: tray?.tray_type || undefined, trayColor: tray?.tray_color || undefined, traySubBrands: tray?.tray_sub_brands || undefined, trayInfoIdx: tray?.tray_info_idx || undefined, extruderId: mappedExtruderId, caliIdx: tray?.cali_idx, savedPresetId: slotPreset?.preset_id, }), }} > {slotVisual} ) : ( setConfigureSlotModal({ amsId: ams.id, trayId: htSlotId, trayCount: ams.tray.length, extruderId: mappedExtruderId, }), }} onAssignSpool={() => setAssignSpoolModal({ printerId: printer.id, amsId: ams.id, trayId: htSlotId, trayInfo: { type: '', material: undefined, profile: '', color: '', location: getAmsLabel(ams.id, ams.tray.length), }, })} > {slotVisual} )}
{/* Stats stacked vertically: Temp on top, Humidity below */} {(ams.humidity != null || ams.temp != null) && (
{ams.temp != null && ( setAmsHistoryModal({ amsId: ams.id, amsLabel: getAmsLabel(ams.id, ams.tray.length), mode: 'temperature', })} compact /> )} {ams.humidity != null && ( setAmsHistoryModal({ amsId: ams.id, amsLabel: getAmsLabel(ams.id, ams.tray.length), mode: 'humidity', })} compact /> )}
)}
); })} {/* External spool(s) - grouped in one card like regular AMS */} {status.vt_tray.length > 0 && (
{t('printers.external')}
1 ? 'grid-cols-2' : 'grid-cols-1'} gap-1.5`}> {[...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)).map((extTray) => { const extTrayId = extTray.id ?? 254; // On dual-nozzle (H2C/H2D), tray_now=254 means "external spool" // generically — use active_extruder to determine L vs R: // extruder 1=left → Ext-L (id=254), extruder 0=right → Ext-R (id=255) const isExtActive = isDualNozzle && effectiveTrayNow === 254 ? (extTrayId === 254 && status.active_extruder === 1) || (extTrayId === 255 && status.active_extruder === 0) : effectiveTrayNow === extTrayId; const slotTrayId = extTrayId - 254; // 0 or 1 const extLabel = isDualNozzle ? (extTrayId === 254 ? t('printers.extL') : t('printers.extR')) : ''; const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null; const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId]; 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); const extInventoryFill = (() => { const sp = extInventoryAssignment?.spool; if (sp && sp.label_weight > 0 && sp.weight_used != null) { return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100); } return null; })(); const extHasFillLevel = extTray.tray_type && extTray.remain >= 0; // If inventory says 0% but AMS reports positive remain, prefer AMS (#676) const extResolvedInventoryFill = (extInventoryFill === 0 && extHasFillLevel && extTray.remain > 0) ? null : extInventoryFill; // Slot-assigned-only fill (when spool has no NFC tag but is slot-assigned) const extSlotAssignmentForFill = spoolmanEnabled && !spoolmanLoading ? spoolmanSlotAssignments?.find(a => a.printer_id === printer.id && a.ams_id === 255 && a.tray_id === slotTrayId) : undefined; const extSlotSpoolForFill = extSlotAssignmentForFill ? spoolmanSpools?.find(s => s.id === extSlotAssignmentForFill.spoolman_spool_id) : undefined; const extSlotSpoolFill = (extSlotSpoolForFill && (extSlotSpoolForFill.label_weight ?? 0) > 0) ? Math.round(Math.max(0, (extSlotSpoolForFill.label_weight ?? 0) - extSlotSpoolForFill.weight_used) / (extSlotSpoolForFill.label_weight ?? 1) * 100) : null; const extEffectiveFill = extSpoolmanFill ?? extSlotSpoolFill ?? extResolvedInventoryFill ?? (extHasFillLevel ? extTray.remain : null); const extFillSource = (extSpoolmanFill !== null || extSlotSpoolFill !== null) ? 'spoolman' as const : extResolvedInventoryFill !== null ? 'inventory' as const : extHasFillLevel ? 'ams' as const : undefined; const extFilamentData = { vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', profile: extSlotPreset?.preset_name || (extSlotSpoolForFill ? [extSlotSpoolForFill.brand, extSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || extSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || extInventoryAssignment?.spool?.slicer_filament_name || extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown', colorName: getColorName(extTray.tray_color || ''), colorHex: extTray.tray_color || null, kFactor: formatKValue(extTray.k), fillLevel: extEffectiveFill, trayUuid: extTray.tray_uuid || null, tagUid: extTray.tag_uid || null, fillSource: extFillSource, }; const isEmpty = !extTray.tray_type; const emptyKind = getEmptySlotKind(extTray); const extSlotContent = (
{/* Filament color circle with 1-based slot number centered inside */}
{extTray.tray_type || t('ams.slotEmpty')}
{extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty && (
)}
{extLabel &&
{extLabel}
}
); const extMenuKey = 255 * 10 + slotTrayId; // unique slotId space for external menu state const isExtMenuOpen = amsSlotMenu?.amsId === 255 && amsSlotMenu?.slotId === extMenuKey; return (
{/* Menu button - appears on hover, hidden when printer busy */} {status?.state !== 'RUNNING' && ( )} {/* Dropdown menu */} {status?.state !== 'RUNNING' && isExtMenuOpen && (
)} {!isEmpty ? ( { const linkTag = (extFilamentData.trayUuid || extFilamentData.tagUid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId)).toUpperCase(); setLinkSpoolModal({ tagUid: extFilamentData.tagUid || linkTag, trayUuid: extFilamentData.trayUuid || '', printerId: printer.id, amsId: 255, trayId: slotTrayId, }); } : undefined, onUnlinkSpool: extLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(extLinkedSpool.id) : undefined, }} inventory={(() => { if (spoolmanEnabled) { if (spoolmanLoading) return undefined; const slotAssignment = extSlotAssignmentForFill; const spoolmanSpool = extSlotSpoolForFill; return { assignedSpool: spoolmanSpool ? { id: spoolmanSpool.id, material: spoolmanSpool.material, brand: spoolmanSpool.brand ?? null, color_name: spoolmanSpool.color_name ?? null, remainingWeightGrams: spoolmanSpool.label_weight ? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used)) : undefined, } : null, onAssignSpool: () => setAssignSpoolModal({ printerId: printer.id, amsId: 255, trayId: slotTrayId, trayInfo: { type: extTray.tray_type || extFilamentData.profile, material: extTray.tray_type ?? undefined, profile: extFilamentData.profile, color: extFilamentData.colorHex || '', location: extLabel || t('printers.external'), }, }), onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(extTray)) ? () => onUnassignSpoolmanSpool?.(spoolmanSpool.id) : undefined, isAssigned: !!slotAssignment || isBambuLabSpool(extTray), }; } const assignment = onGetAssignment?.(printer.id, 255, slotTrayId); return { assignedSpool: assignment?.spool ? { id: assignment.spool.id, material: assignment.spool.material, brand: assignment.spool.brand, color_name: assignment.spool.color_name, remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)), } : null, onAssignSpool: () => setAssignSpoolModal({ printerId: printer.id, amsId: 255, trayId: slotTrayId, trayInfo: { type: extTray.tray_type || extFilamentData.profile, material: extTray.tray_type ?? undefined, profile: extFilamentData.profile, color: extFilamentData.colorHex || '', location: extLabel || t('printers.external'), }, }), onUnassignSpool: (assignment && !isBambuLabSpool(extTray)) ? () => onUnassignSpool?.(printer.id, 255, slotTrayId) : undefined, isAssigned: !!assignment || isBambuLabSpool(extTray), }; })()} configureSlot={{ enabled: hasPermission('printers:control'), onConfigure: () => setConfigureSlotModal({ amsId: 255, trayId: slotTrayId, trayCount: 1, trayType: extTray.tray_type || undefined, trayColor: extTray.tray_color || undefined, traySubBrands: extTray.tray_sub_brands || undefined, trayInfoIdx: extTray.tray_info_idx || undefined, extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined, caliIdx: extTray.cali_idx, savedPresetId: extSlotPreset?.preset_id, }), }} > {extSlotContent} ) : ( setConfigureSlotModal({ amsId: 255, trayId: slotTrayId, trayCount: 1, extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined, }), }} onAssignSpool={() => setAssignSpoolModal({ printerId: printer.id, amsId: 255, trayId: slotTrayId, trayInfo: { type: '', material: undefined, profile: '', color: '', location: `External Slot ${slotTrayId + 1}`, }, })} > {extSlotContent} )}
); })}
)}
)}
); })()} )} {/* Smart Plug Controls - hidden in compact mode */} {smartPlug && viewMode === 'expanded' && (
{/* Plug name and status */}
{smartPlug.name} {plugStatus && ( {plugStatus.state || '?'} {plugStatus.state === 'ON' && plugStatus.energy?.power != null && ( · {Math.round(plugStatus.energy.power)}W )} )}
{/* Spacer */}
{/* Power buttons */}
{/* Auto-off toggle */}
{/* HA entity buttons row */} {scriptPlugs && scriptPlugs.length > 0 && (
HA:
{scriptPlugs.map(script => { const isScript = script.ha_entity_id?.startsWith('script.'); return ( ); })}
)}
)} {/* Connection Info & Actions - hidden in compact mode */} {viewMode === 'expanded' && (
{/* Chamber Light */} {/* Camera Button */} {/* Split button: main part toggles detection, chevron opens modal */}
{isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && ( )}
)} {/* File Manager Modal */} {showFileManager && ( setShowFileManager(false)} /> )} {/* Upload for Print Modal */} {showUploadForPrint && ( setShowUploadForPrint(false)} onUploadComplete={() => {}} autoUpload accept=".gcode,.3mf" validateFile={(file) => { const lower = file.name.toLowerCase(); if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) { return t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed'); } }} onFileUploaded={(uploadedFile) => { // Check printer compatibility if sliced_for_model is available in metadata const slicedFor = (uploadedFile.metadata as Record)?.sliced_for_model as string | undefined; const printerModel = mapModelCode(printer.model); if (slicedFor && printerModel && slicedFor.toLowerCase() !== printerModel.toLowerCase()) { api.deleteLibraryFile(uploadedFile.id).catch(() => {}); return t('printers.incompatibleFile', 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}', { slicedFor, printerModel }); } setPrintAfterUpload({ id: uploadedFile.id, filename: uploadedFile.filename }); }} /> )} {/* Print Modal (after upload) */} {printAfterUpload && ( setPrintAfterUpload(null)} onSuccess={() => setPrintAfterUpload(null)} cleanupLibraryAfterDispatch /> )} {/* MQTT Debug Modal */} {showMQTTDebug && ( setShowMQTTDebug(false)} /> )} {showDiagnostic && ( setShowDiagnostic(false)} /> )} {showPrinterInfo && ( )} {/* Plate Check Result Modal */} {plateCheckResult && (
closePlateCheckModal()}>
e.stopPropagation()}>
{plateCheckResult.needs_calibration ? ( ) : plateCheckResult.is_empty ? ( ) : ( )}

Build Plate Check

{plateCheckResult.reference_count !== undefined && plateCheckResult.max_references && ( {plateCheckResult.reference_count}/{plateCheckResult.max_references} refs )}
{plateCheckResult.needs_calibration ? ( <>

{t('printers.plateDetection.calibrationRequired')}

{t('printers.plateDetection.calibrationDescription')}

) : ( <>

{plateCheckResult.is_empty ? t('printers.plateDetection.plateEmpty') : t('printers.plateDetection.objectsDetected')}

{t('printers.plateDetection.confidence')}: {Math.round(plateCheckResult.confidence * 100)}% | {t('printers.plateDetection.difference')}: {plateCheckResult.difference_percent.toFixed(1)}%

{plateCheckResult.debug_image_url && (

{t('printers.plateDetection.analysisPreview')}

{t('printers.plateDetection.analysisPreview')}

{t('printers.plateDetection.analysisLegend')}

)}

{plateCheckResult.message}

)} {/* Saved References Grid */} {plateReferences && plateReferences.references.length > 0 && (

{t('printers.plateDetection.savedReferences', { count: plateReferences.references.length, max: plateReferences.max_references })}

{plateReferences.references.map((ref) => (
{ref.label {/* Delete button */} {/* Label */} {editingRefLabel?.index === ref.index ? ( setEditingRefLabel({ ...editingRefLabel, label: e.target.value })} onBlur={() => handleUpdateRefLabel(ref.index, editingRefLabel.label)} onKeyDown={(e) => { if (e.key === 'Enter') handleUpdateRefLabel(ref.index, editingRefLabel.label); if (e.key === 'Escape') setEditingRefLabel(null); }} className="w-full mt-1 px-1 py-0.5 text-xs bg-bambu-dark-tertiary border border-bambu-green rounded text-white" autoFocus placeholder={t('printers.plateDetection.labelPlaceholder')} /> ) : (

setEditingRefLabel({ index: ref.index, label: ref.label })} title={ref.label ? t('printers.plateDetection.clickToEdit', { label: ref.label }) : t('printers.plateDetection.clickToAddLabel')} > {ref.label || {t('printers.noLabel')}}

)} {/* Timestamp */}

{ref.timestamp ? parseUTCDate(ref.timestamp)?.toLocaleDateString() ?? '' : ''}

))}
)} {/* ROI Editor */} {!plateCheckResult.needs_calibration && (

{t('printers.roi.title')}

{!editingRoi ? ( ) : (
)}
{editingRoi ? (
setEditingRoi({ ...editingRoi, x: parseFloat(e.target.value) })} className="w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500" /> {Math.round(editingRoi.x * 100)}%
setEditingRoi({ ...editingRoi, y: parseFloat(e.target.value) })} className="w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500" /> {Math.round(editingRoi.y * 100)}%
setEditingRoi({ ...editingRoi, w: parseFloat(e.target.value) })} className="w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500" /> {Math.round(editingRoi.w * 100)}%
setEditingRoi({ ...editingRoi, h: parseFloat(e.target.value) })} className="w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500" /> {Math.round(editingRoi.h * 100)}%

{t('printers.roi.instruction')}

) : (

Current: X={Math.round((plateCheckResult.roi?.x || 0.15) * 100)}%, Y={Math.round((plateCheckResult.roi?.y || 0.35) * 100)}%, W={Math.round((plateCheckResult.roi?.w || 0.70) * 100)}%, H={Math.round((plateCheckResult.roi?.h || 0.55) * 100)}%

)}
)}
{plateCheckResult.needs_calibration ? ( <> ) : ( <> )}
)} {/* Power On Confirmation */} {showPowerOnConfirm && smartPlug && ( { powerControlMutation.mutate('on'); setShowPowerOnConfirm(false); }} onCancel={() => setShowPowerOnConfirm(false)} /> )} {/* Power Off Confirmation */} {showPowerOffConfirm && smartPlug && ( { powerControlMutation.mutate('off'); setShowPowerOffConfirm(false); }} onCancel={() => setShowPowerOffConfirm(false)} /> )} {/* HA entity toggle confirmation (Show on Printer Card switches) */} {haToggleConfirm && ( { runScriptMutation.mutate({ id: haToggleConfirm.id, action: 'toggle' }); setHaToggleConfirm(null); }} onCancel={() => setHaToggleConfirm(null)} /> )} {/* Stop Print Confirmation */} {showStopConfirm && ( { stopPrintMutation.mutate(); setShowStopConfirm(false); }} onCancel={() => setShowStopConfirm(false)} /> )} {/* Pause Print Confirmation */} {showPauseConfirm && ( { pausePrintMutation.mutate(); setShowPauseConfirm(false); }} onCancel={() => setShowPauseConfirm(false)} /> )} {/* Resume Print Confirmation */} {showResumeConfirm && ( { resumePrintMutation.mutate(); setShowResumeConfirm(false); }} onCancel={() => setShowResumeConfirm(false)} /> )} {/* Bed Jog — not-homed warning (Studio-style) */} {showNotHomedModal && (

{t('printers.bedJog.notHomedTitle')}

{t('printers.bedJog.notHomedMessage')}

)} {/* Skip Objects Modal */} setShowSkipObjectsModal(false)} /> {/* HMS Error Modal */} {showHMSModal && ( setShowHMSModal(false)} printerId={printer.id} hasPermission={hasPermission} /> )} {/* AMS History Modal */} {amsHistoryModal && ( setAmsHistoryModal(null)} printerId={printer.id} printerName={printer.name} amsId={amsHistoryModal.amsId} amsLabel={amsHistoryModal.amsLabel} initialMode={amsHistoryModal.mode} thresholds={amsThresholds} /> )} {/* Link Spool Modal */} {linkSpoolModal && ( setLinkSpoolModal(null)} tagUid={linkSpoolModal.tagUid} trayUuid={linkSpoolModal.trayUuid} printerId={linkSpoolModal.printerId} amsId={linkSpoolModal.amsId} trayId={linkSpoolModal.trayId} /> )} {/* Assign Spool Modal */} {assignSpoolModal && ( setAssignSpoolModal(null)} printerId={assignSpoolModal.printerId} amsId={assignSpoolModal.amsId} trayId={assignSpoolModal.trayId} trayInfo={assignSpoolModal.trayInfo} spoolmanEnabled={!!spoolmanEnabled} /> )} {/* Configure AMS Slot Modal */} {configureSlotModal && ( setConfigureSlotModal(null)} printerId={printer.id} slotInfo={configureSlotModal} printerModel={mapModelCode(printer.model) || undefined} onSuccess={() => { // Refresh slot presets to show updated profile name queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] }); // Printer status will update automatically via WebSocket when AMS data changes queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }} /> )} {/* Edit Printer Modal */} {showEditModal && ( setShowEditModal(false)} /> )} {/* Firmware Update Modal */} {showFirmwareModal && firmwareInfo && ( setShowFirmwareModal(false)} /> )} {/* AMS Slot Menu Backdrop - closes menu when clicking outside */} {amsSlotMenu && (
setAmsSlotMenu(null)} /> )} {/* AMS Drying Popover — fixed position to avoid overflow/z-index issues */} {dryingPopoverAmsId !== null && dryingPopoverPos && (() => { const maxTemp = dryingPopoverModuleType === 'n3s' ? 85 : 65; const sliderMin = 35; const sliderMax = maxTemp + 10; return ( <> {/* Backdrop */}
setDryingPopoverAmsId(null)} /> {/* Popover */}
e.stopPropagation()} > {/* Header */}
{t('printers.drying.start')}
{/* Body */}
{/* Filament type select */}
{/* Temperature */}
setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value) || 45)))} className="w-12 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> °C
setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value))))} className="w-full h-1 accent-amber-500 cursor-pointer" />
45°C {maxTemp}°C
{/* Duration */}
setDryingDuration(Math.min(24, Math.max(1, Number(e.target.value) || 1)))} className="w-10 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> {t('printers.drying.hours')}
setDryingDuration(Number(e.target.value))} className="w-full h-1 accent-amber-500 cursor-pointer" />
1h 24h
{/* Rotate tray */}
{/* Footer */}
); })()} ); } function AddPrinterModal({ onClose, onAdd, existingSerials, }: { onClose: () => void; onAdd: (data: PrinterCreate) => void; existingSerials: string[]; }) { const { t } = useTranslation(); const [form, setForm] = useState({ name: '', serial_number: '', ip_address: '', access_code: '', model: '', location: '', auto_archive: true, }); // Discovery state const [discovering, setDiscovering] = useState(false); const [discovered, setDiscovered] = useState([]); const [discoveryError, setDiscoveryError] = useState(''); const [hasScanned, setHasScanned] = useState(false); const [isDocker, setIsDocker] = useState(false); const [detectedSubnets, setDetectedSubnets] = useState([]); const [subnet, setSubnet] = useState(''); const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 }); const [showDiagnostic, setShowDiagnostic] = useState(false); // Setup-time pre-flight: run the connection diagnostic on save and warn // (not block) when checks fail, so the user doesn't add a printer that // immediately shows offline. checkingSave = probe in flight; saveWarning = // failed result awaiting an explicit "save anyway". const [checkingSave, setCheckingSave] = useState(false); const [saveWarning, setSaveWarning] = useState(null); // Fetch discovery info on mount useEffect(() => { discoveryApi.getInfo().then(info => { setIsDocker(info.is_docker); if (info.subnets.length > 0) { setDetectedSubnets(info.subnets); setSubnet(info.subnets[0]); } }).catch(() => { // Ignore errors, assume not Docker }); }, []); // Filter out already-added printers const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial)); const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault(); setCheckingSave(true); try { const result = await api.diagnoseConnection({ ip_address: form.ip_address.trim(), serial_number: form.serial_number.trim() || undefined, access_code: form.access_code || undefined, }); if (result.checks.some((c) => c.status === 'fail')) { setSaveWarning(result); return; } } catch { // Diagnostic infrastructure failed — never block the save on it. } finally { setCheckingSave(false); } onAdd(form); }; const startDiscovery = async () => { setDiscoveryError(''); setDiscovered([]); setDiscovering(true); setHasScanned(false); setScanProgress({ scanned: 0, total: 0 }); try { if (isDocker) { // Use subnet scanning for Docker await discoveryApi.startSubnetScan(subnet); // Poll for scan status and results const pollInterval = setInterval(async () => { try { const status = await discoveryApi.getScanStatus(); setScanProgress({ scanned: status.scanned, total: status.total }); const printers = await discoveryApi.getDiscoveredPrinters(); setDiscovered(printers); if (!status.running) { clearInterval(pollInterval); setDiscovering(false); setHasScanned(true); } } catch (e) { console.error('Failed to get scan status:', e); } }, 500); } else { // Use SSDP discovery for native installs await discoveryApi.startDiscovery(10); // Poll for discovered printers every second const pollInterval = setInterval(async () => { try { const printers = await discoveryApi.getDiscoveredPrinters(); setDiscovered(printers); } catch (e) { console.error('Failed to get discovered printers:', e); } }, 1000); // Stop after 10 seconds setTimeout(async () => { clearInterval(pollInterval); try { await discoveryApi.stopDiscovery(); } catch { // Ignore stop errors } setDiscovering(false); setHasScanned(true); // Final fetch try { const printers = await discoveryApi.getDiscoveredPrinters(); setDiscovered(printers); } catch (e) { console.error('Failed to get final discovered printers:', e); } }, 10000); } } catch (e) { console.error('Failed to start discovery:', e); setDiscoveryError(e instanceof Error ? e.message : t('printers.discovery.failedToStart')); setDiscovering(false); setHasScanned(true); } }; // Reuse module-level mapModelCode const selectPrinter = (printer: DiscoveredPrinter) => { // Don't pre-fill serial if it's a placeholder (unknown-*) - user needs to enter actual serial const serialNumber = printer.serial.startsWith('unknown-') ? '' : printer.serial; setForm({ ...form, name: printer.name || '', serial_number: serialNumber, ip_address: printer.ip_address, model: mapModelCode(printer.model), }); // Clear discovery results after selection setDiscovered([]); }; // Cleanup discovery on unmount useEffect(() => { return () => { discoveryApi.stopDiscovery().catch(() => {}); discoveryApi.stopSubnetScan().catch(() => {}); }; }, []); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); return ( <>
e.stopPropagation()}>

{t('printers.addPrinter')}

{/* Discovery Section */}
{isDocker && (
{detectedSubnets.length > 0 ? ( ) : ( setSubnet(e.target.value)} placeholder="192.168.1.0/24" disabled={discovering} /> )}

{t('printers.discovery.dockerNote')}

)} {discoveryError && (
{discoveryError}
)} {newPrinters.length > 0 && (
{newPrinters.map((printer) => (
selectPrinter(printer)} >

{printer.name || printer.serial}

{mapModelCode(printer.model) || t('printers.discovery.unknown')} • {printer.ip_address} {printer.serial.startsWith('unknown-') && ( • {t('printers.discovery.serialRequired')} )}

))}
)} {discovering && (

{isDocker ? t('printers.discovery.scanningSubnet') : t('printers.discovery.scanningNetwork')}

)} {hasScanned && !discovering && discovered.length === 0 && (

{isDocker ? t('printers.discovery.noPrintersFoundSubnet') : t('printers.discovery.noPrintersFoundNetwork')}

)} {hasScanned && !discovering && discovered.length > 0 && newPrinters.length === 0 && (

{t('printers.discovery.allConfigured')}

)}
setForm({ ...form, name: e.target.value })} placeholder={t('printers.modal.myPrinter')} />
setForm({ ...form, ip_address: e.target.value })} placeholder="192.168.1.100 or printer.local" />
setForm({ ...form, serial_number: e.target.value })} placeholder="01P00A000000000" />
setForm({ ...form, access_code: e.target.value })} placeholder={t('printers.modal.fromPrinterSettings')} />
setForm({ ...form, location: e.target.value })} placeholder={t('printers.modal.locationPlaceholder')} />

{t('printers.locationHelp')}

setForm({ ...form, auto_archive: e.target.checked })} className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green" />
{saveWarning ? (

{t('printers.addPreflight.warning')}

) : (
)}
{showDiagnostic && ( setShowDiagnostic(false)} /> )} ); } function FirmwareUpdateModal({ printer, firmwareInfo, onClose, }: { printer: Printer; firmwareInfo: FirmwareUpdateInfo; onClose: () => void; }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission } = useAuth(); const canUpdate = hasPermission('firmware:update'); const [uploadStatus, setUploadStatus] = useState(null); const [isUploading, setIsUploading] = useState(false); const [pollInterval, setPollInterval] = useState(null); const [selectedVersion, setSelectedVersion] = useState( firmwareInfo.update_available ? firmwareInfo.latest_version : null, ); // Prepare check query — runs when a version is selected and user can update const { data: prepareInfo, isLoading: isPreparing } = useQuery({ queryKey: ['firmwarePrepare', printer.id, selectedVersion], queryFn: () => firmwareApi.prepareUpload(printer.id, selectedVersion ?? undefined), staleTime: 30000, enabled: !!selectedVersion && canUpdate && !isUploading, }); // Start upload mutation const uploadMutation = useMutation({ mutationFn: () => firmwareApi.startUpload(printer.id, selectedVersion ?? undefined), onSuccess: () => { setIsUploading(true); // Start polling for status const interval = setInterval(async () => { try { const status = await firmwareApi.getUploadStatus(printer.id); setUploadStatus(status); if (status.status === 'complete' || status.status === 'error') { clearInterval(interval); setPollInterval(null); setIsUploading(false); if (status.status === 'complete') { showToast(t('printers.firmwareModal.uploadedToast'), 'success'); queryClient.invalidateQueries({ queryKey: ['firmwareUpdate', printer.id] }); } } } catch { // Ignore errors during polling } }, 2000); setPollInterval(interval); }, onError: (error: Error) => { showToast(t('printers.firmwareModal.uploadFailed', { error: error.message }), 'error'); setIsUploading(false); }, }); // Cleanup on unmount useEffect(() => { return () => { if (pollInterval) clearInterval(pollInterval); }; }, [pollInterval]); const handleStartUpload = () => { setUploadStatus(null); uploadMutation.mutate(); }; return (
{firmwareInfo.update_available ? : }

{firmwareInfo.update_available ? t('printers.firmwareModal.title') : t('printers.firmwareModal.titleUpToDate')}

{printer.name}

{/* Version Info */} {(() => { const selectedEntry = selectedVersion ? firmwareInfo.available_versions?.find((v) => v.version === selectedVersion) : null; const displayVersion = selectedVersion ?? firmwareInfo.latest_version; const displayNotes = selectedEntry?.release_notes ?? firmwareInfo.release_notes; const showSecondLine = !!displayVersion && displayVersion !== firmwareInfo.current_version; return (
{t('printers.firmwareModal.currentVersion')} {firmwareInfo.current_version || t('common.unknown')}
{showSecondLine && (
{t('printers.firmwareModal.latestVersion')} {displayVersion}
)} {displayNotes && (
{t('printers.firmwareModal.releaseNotes')}
{displayNotes}
)}
); })()} {/* Available versions list */} {firmwareInfo.available_versions && firmwareInfo.available_versions.length > 0 && !isUploading && uploadStatus?.status !== 'complete' && (
{t('printers.firmwareModal.availableVersions')}
{firmwareInfo.available_versions.map((v) => { const isCurrent = firmwareInfo.current_version === v.version; const isSelected = selectedVersion === v.version; const cmp = firmwareInfo.current_version ? compareFwVersions(v.version, firmwareInfo.current_version) : 0; const relLabel = isCurrent ? t('printers.firmwareModal.currentBadge') : cmp > 0 ? t('printers.firmwareModal.newerBadge') : t('printers.firmwareModal.olderBadge'); const relClass = isCurrent ? 'text-bambu-gray' : cmp > 0 ? 'text-orange-400' : 'text-blue-400'; return ( ); })}
)} {/* Status / Progress (only when a version is selected) */} {!selectedVersion ? null : isPreparing ? (
{t('printers.firmwareModal.checkingPrereqs')}
) : prepareInfo && !isUploading && !uploadStatus ? (
{prepareInfo.can_proceed ? (
{t('printers.firmwareModal.sdCardReady')}
) : (
{prepareInfo.errors.map((error, i) => (
{error}
))}
)}
) : null} {/* Upload Progress */} {(isUploading || uploadStatus) && uploadStatus && (
{uploadStatus.status} {uploadStatus.progress}%

{uploadStatus.message}

{uploadStatus.error && (

{uploadStatus.error}

)}
)} {/* Success Message */} {uploadStatus?.status === 'complete' && (

{t('printers.firmwareModal.uploadedSuccess')}

{t('printers.firmwareModal.applyInstructions')}

  1. {t('printers.firmwareModal.step4')}
)} {/* Buttons */}
{prepareInfo?.can_proceed && !isUploading && uploadStatus?.status !== 'complete' && canUpdate && ( )}
); } function EditPrinterModal({ printer, onClose, }: { printer: Printer; onClose: () => void; }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [form, setForm] = useState({ name: printer.name, ip_address: printer.ip_address, access_code: '', model: printer.model || '', location: printer.location || '', auto_archive: printer.auto_archive, }); // Setup-time pre-flight — same warn-on-save as the Add-Printer dialog, so an // edit that breaks connectivity (e.g. a mistyped IP) is caught before save. const [checkingSave, setCheckingSave] = useState(false); const [saveWarning, setSaveWarning] = useState(null); const updateMutation = useMutation({ mutationFn: (data: Partial) => api.updatePrinter(printer.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printers'] }); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); onClose(); }, onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUpdate'), 'error'), }); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); const doSave = () => { const data: Partial = { name: form.name, ip_address: form.ip_address, model: form.model || undefined, location: form.location || undefined, auto_archive: form.auto_archive, }; // Only include access_code if it was changed if (form.access_code) { data.access_code = form.access_code; } updateMutation.mutate(data); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setCheckingSave(true); try { const result = await api.diagnoseConnection({ ip_address: form.ip_address.trim(), serial_number: printer.serial_number, access_code: form.access_code || undefined, }); if (result.checks.some((c) => c.status === 'fail')) { setSaveWarning(result); return; } } catch { // Diagnostic infrastructure failed — never block the save on it. } finally { setCheckingSave(false); } doSave(); }; return (
e.stopPropagation()}>

{t('printers.editPrinter')}

setForm({ ...form, name: e.target.value })} placeholder={t('printers.modal.myPrinter')} />
setForm({ ...form, ip_address: e.target.value })} placeholder="192.168.1.100 or printer.local" />

{t('printers.serialCannotBeChanged')}

setForm({ ...form, access_code: e.target.value })} placeholder={t('printers.accessCodePlaceholder')} />
setForm({ ...form, location: e.target.value })} placeholder={t('printers.modal.locationPlaceholder')} />

{t('printers.locationHelp')}

setForm({ ...form, auto_archive: e.target.checked })} className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green" />
{saveWarning ? (

{t('printers.addPreflight.warning')}

) : (
)}
); } // Component to check if a printer is offline (for power dropdown) function usePrinterOfflineStatus(printerId: number) { const { data: status } = useQuery({ queryKey: ['printerStatus', printerId], queryFn: () => api.getPrinterStatus(printerId), refetchInterval: 30000, }); return !status?.connected; } // Power dropdown item for an offline printer function PowerDropdownItem({ printer, plug, onPowerOn, isPowering, }: { printer: Printer; plug: { id: number; name: string }; onPowerOn: (plugId: number) => void; isPowering: boolean; }) { const isOffline = usePrinterOfflineStatus(printer.id); // Fetch plug status const { data: plugStatus } = useQuery({ queryKey: ['smartPlugStatus', plug.id], queryFn: () => api.getSmartPlugStatus(plug.id), refetchInterval: 10000, }); // Only show if printer is offline if (!isOffline) { return null; } return (
{printer.name} {plugStatus && ( {plugStatus.state || '?'} )}
); } export function PrintersPage() { const { t } = useTranslation(); const [showAddModal, setShowAddModal] = useState(false); const [hideDisconnected, setHideDisconnected] = useState(() => { return localStorage.getItem('hideDisconnectedPrinters') === 'true'; }); const [showPowerDropdown, setShowPowerDropdown] = useState(false); const [poweringOn, setPoweringOn] = useState(null); const [sortBy, setSortBy] = useState(() => { return (localStorage.getItem('printerSortBy') as SortOption) || 'name'; }); const [sortAsc, setSortAsc] = useState(() => { return localStorage.getItem('printerSortAsc') !== 'false'; }); // Card size: 1=small, 2=medium, 3=large, 4=xl const [cardSize, setCardSize] = useState(() => { const saved = localStorage.getItem('printerCardSize'); return saved ? parseInt(saved, 10) : 2; // Default to medium }); // Derive viewMode from cardSize: S=compact, M/L/XL=expanded const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded'; const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [locationFilter, setLocationFilter] = useState('all'); const [statusCacheVersion, setStatusCacheVersion] = useState(0); const [collapsedSections, setCollapsedSections] = useState>(() => { try { const saved = localStorage.getItem('printerCollapsedSections'); return saved ? JSON.parse(saved) : {}; } catch { return {}; } }); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission } = useAuth(); // Embedded camera viewer state - supports multiple simultaneous viewers // Persisted to localStorage so cameras reopen after navigation const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState>(() => { // Initialize from localStorage if camera_view_mode is embedded const saved = localStorage.getItem('openEmbeddedCameras'); if (saved) { try { const cameras = JSON.parse(saved) as Array<{ id: number; name: string }>; return new Map(cameras.map(c => [c.id, c])); } catch { return new Map(); } } return new Map(); }); // Persist open cameras to localStorage when they change useEffect(() => { const cameras = Array.from(embeddedCameraPrinters.values()); if (cameras.length > 0) { localStorage.setItem('openEmbeddedCameras', JSON.stringify(cameras)); } else { localStorage.removeItem('openEmbeddedCameras'); } }, [embeddedCameraPrinters]); const { data: printers, isLoading } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); // Fetch the UI-rendering subset of settings. Uses /ui-preferences (not /settings) // so users with printers:read but no settings:read still get the values needed // to render the clear-plate button, drying presets, AMS thresholds, etc. (#1293). const { data: settings } = useQuery({ queryKey: ['ui-preferences'], queryFn: api.getUiPreferences, }); // Compute drying presets: user-configured (from settings) merged over built-in defaults const effectiveDryingPresets = useMemo(() => { if (settings?.drying_presets) { try { const userPresets = JSON.parse(settings.drying_presets); if (typeof userPresets === 'object' && userPresets !== null && Object.keys(userPresets).length > 0) { return { ...DRYING_PRESETS, ...userPresets }; } } catch { /* ignore parse errors, use defaults */ } } return DRYING_PRESETS; }, [settings?.drying_presets]); // Close embedded cameras if mode changes to 'window' useEffect(() => { if (settings?.camera_view_mode === 'window' && embeddedCameraPrinters.size > 0) { setEmbeddedCameraPrinters(new Map()); } }, [settings?.camera_view_mode, embeddedCameraPrinters.size]); // Fetch all smart plugs to know which printers have them const { data: smartPlugs } = useQuery({ queryKey: ['smart-plugs'], queryFn: api.getSmartPlugs, }); // Fetch maintenance overview for all printers to show badges const { data: maintenanceOverview } = useQuery({ queryKey: ['maintenanceOverview'], queryFn: api.getMaintenanceOverview, staleTime: 60 * 1000, // 1 minute }); // Fetch Spoolman status to enable link spool feature const { data: spoolmanStatus } = useQuery({ queryKey: ['spoolman-status'], queryFn: api.getSpoolmanStatus, staleTime: 60 * 1000, // 1 minute }); 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'], queryFn: api.getUnlinkedSpools, enabled: !!spoolmanEnabled, staleTime: 30 * 1000, // 30 seconds }); const hasUnlinkedSpools = unlinkedSpools && unlinkedSpools.length > 0; // Fetch linked spools map (tag -> spool_id) to know which spools are already in Spoolman const { data: linkedSpoolsData } = useQuery({ queryKey: ['linked-spools'], queryFn: api.getLinkedSpools, enabled: !!spoolmanEnabled, staleTime: 30 * 1000, // 30 seconds }); const linkedSpools = linkedSpoolsData?.linked; // Fetch spool assignments for inventory feature const { data: spoolAssignments } = useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments(), enabled: hasPermission('inventory:view_assignments'), staleTime: 30 * 1000, }); const unassignMutation = useMutation({ mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) => api.unassignSpool(printerId, amsId, trayId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['spool-assignments'] }); }, }); const { data: spoolmanSpools, isLoading: spoolmanSpoolsLoading } = useQuery({ queryKey: ['spoolman-inventory-spools'], queryFn: () => api.getSpoolmanInventorySpools(false), enabled: !!spoolmanEnabled, staleTime: 30 * 1000, }); const { data: spoolmanSlotAssignments, isLoading: spoolmanAssignmentsLoading } = useQuery({ queryKey: ['spoolman-slot-assignments'], queryFn: () => api.getSpoolmanSlotAssignments(), enabled: !!spoolmanEnabled, staleTime: 30 * 1000, }); const unassignSpoolmanMutation = useMutation({ mutationFn: (spoolmanSpoolId: number) => api.unassignSpoolmanSlot(spoolmanSpoolId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] }); }, }); // Helper to find assignment for a specific slot const getAssignment = (printerId: number, amsId: number | string, trayId: number | string): SpoolAssignment | undefined => { return spoolAssignments?.find( (a) => a.printer_id === printerId && a.ams_id === Number(amsId) && a.tray_id === Number(trayId) ); }; // Create a map of printer_id -> maintenance info for quick lookup const maintenanceByPrinter = maintenanceOverview?.reduce( (acc, overview) => { acc[overview.printer_id] = { due_count: overview.due_count, warning_count: overview.warning_count, total_print_hours: overview.total_print_hours, }; return acc; }, {} as Record ) || {}; // Create a map of printer_id -> smart plug const smartPlugByPrinter = smartPlugs?.reduce( (acc, plug) => { if (plug.printer_id) { acc[plug.printer_id] = plug; } return acc; }, {} as Record ) || {}; const addMutation = useMutation({ mutationFn: api.createPrinter, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printers'] }); queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] }); setShowAddModal(false); }, onError: (error: Error) => { // Localized message when the backend returns a stable error code; // the raw message is an English fallback for non-UI clients. if (error instanceof ApiError && error.code === 'printer_connection_failed') { showToast(t('printers.toast.connectionFailedNotAdded'), 'error'); return; } showToast(error.message || t('printers.toast.failedToAdd'), 'error'); }, }); const powerOnMutation = useMutation({ mutationFn: (plugId: number) => api.controlSmartPlug(plugId, 'on'), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); setPoweringOn(null); }, onError: () => { setPoweringOn(null); }, }); // Bulk selection state const [selectedPrinterIds, setSelectedPrinterIds] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); const [bulkConfirmAction, setBulkConfirmAction] = useState<'stop' | 'pause' | 'clearPlate' | null>(null); const [bulkActionPending, setBulkActionPending] = useState(false); const selectionMode = isSelectionMode || selectedPrinterIds.size > 0; const toggleSelect = useCallback((id: number) => { setSelectedPrinterIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const clearSelection = useCallback(() => { setSelectedPrinterIds(new Set()); setIsSelectionMode(false); }, []); // Escape key exits selection mode useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && selectionMode) { clearSelection(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectionMode, clearSelection]); const executeBulkAction = useCallback(async (action: 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS') => { setBulkActionPending(true); const ids = Array.from(selectedPrinterIds); // Filter to only applicable printers based on cached state const applicableIds = ids.filter(id => { const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', id]); if (!status?.connected) return false; switch (action) { case 'stop': return status.state === 'RUNNING' || status.state === 'PAUSE'; case 'pause': return status.state === 'RUNNING'; case 'resume': return status.state === 'PAUSE'; case 'clearPlate': return !!(status as { awaiting_plate_clear?: boolean }).awaiting_plate_clear; case 'clearHMS': return status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0; default: return false; } }); if (applicableIds.length === 0) { showToast(t('printers.bulk.noneApplicable'), 'error'); setBulkActionPending(false); setBulkConfirmAction(null); return; } const apiCall = { stop: api.stopPrint, pause: api.pausePrint, resume: api.resumePrint, clearPlate: api.clearPlate, clearHMS: api.clearHMSErrors, }[action]; const results = await Promise.allSettled( applicableIds.map(id => apiCall(id)) ); const succeeded = results.filter(r => r.status === 'fulfilled').length; const failed = results.filter(r => r.status === 'rejected').length; if (failed === 0) { showToast(t('printers.bulk.success', { action: t(`printers.bulk.actions.${action}`), count: succeeded })); } else { showToast(t('printers.bulk.partial', { succeeded, failed }), 'error'); } // Invalidate status queries for affected printers applicableIds.forEach(id => { queryClient.invalidateQueries({ queryKey: ['printerStatus', id] }); }); setBulkActionPending(false); setBulkConfirmAction(null); }, [selectedPrinterIds, queryClient, showToast, t]); const handleBulkAction = useCallback((action: 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS') => { // Actions that need confirmation if (action === 'stop' || action === 'pause' || action === 'clearPlate') { setBulkConfirmAction(action); } else { executeBulkAction(action); } }, [executeBulkAction]); const toggleHideDisconnected = () => { const newValue = !hideDisconnected; setHideDisconnected(newValue); localStorage.setItem('hideDisconnectedPrinters', String(newValue)); }; const handleSortChange = (newSort: SortOption) => { setSortBy(newSort); localStorage.setItem('printerSortBy', newSort); }; const toggleSortDirection = () => { const newAsc = !sortAsc; setSortAsc(newAsc); localStorage.setItem('printerSortAsc', String(newAsc)); }; // Grid classes based on card size (1=small, 2=medium, 3=large, 4=xl) const getGridClasses = () => { switch (cardSize) { case 1: return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'; // S: many small cards case 2: return 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'; // M: medium cards case 3: return 'grid-cols-1 lg:grid-cols-2'; // L: large cards, 2 columns max case 4: return 'grid-cols-1'; // XL: single column, full width default: return 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'; } }; const cardSizeLabels = ['S', 'M', 'L', 'XL']; // Increment version counter whenever a printer status cache entry is updated so // filteredPrinters re-computes reactively on WebSocket-driven status changes. useEffect(() => { const unsubscribe = queryClient.getQueryCache().subscribe((event) => { if ( event.type === 'updated' && Array.isArray(event.query.queryKey) && event.query.queryKey[0] === 'printerStatus' ) { setStatusCacheVersion(v => v + 1); } }); return unsubscribe; }, [queryClient]); // Filter printers by search term, status, and location const filteredPrinters = useMemo(() => { if (!printers) return []; let result = printers; // Text search if (search.trim()) { const q = search.trim().toLowerCase(); result = result.filter(p => p.name.toLowerCase().includes(q) || (p.model || '').toLowerCase().includes(q) || (p.location || '').toLowerCase().includes(q) || (p.serial_number || '').toLowerCase().includes(q) ); } // Location filter if (locationFilter !== 'all') { result = result.filter(p => (p.location || '') === locationFilter); } // Status filter if (statusFilter !== 'all') { result = result.filter(p => { const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]); if (!status?.connected) return statusFilter === 'offline'; const hmsErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : []; switch (statusFilter) { case 'printing': return status.state === 'RUNNING'; case 'paused': return status.state === 'PAUSE'; case 'finished': return status.state === 'FINISH'; case 'error': return status.state === 'FAILED' || hmsErrors.length > 0; case 'idle': return status.state !== 'RUNNING' && status.state !== 'PAUSE' && status.state !== 'FINISH' && status.state !== 'FAILED' && hmsErrors.length === 0; case 'offline': return false; // Connected printers are never offline default: return true; } }); } return result; // eslint-disable-next-line react-hooks/exhaustive-deps -- statusCacheVersion is intentional: it forces recompute when WebSocket updates printer status cache }, [printers, search, statusFilter, locationFilter, queryClient, statusCacheVersion]); // Derive unique locations for the location filter dropdown const availableLocations = useMemo(() => { if (!printers) return []; return [...new Set(printers.map(p => p.location || '').filter(Boolean))].sort(); }, [printers]); // Sort printers based on selected option const sortedPrinters = useMemo(() => { const sorted = [...filteredPrinters]; switch (sortBy) { case 'name': sorted.sort((a, b) => a.name.localeCompare(b.name)); break; case 'model': sorted.sort((a, b) => (a.model || '').localeCompare(b.model || '')); break; case 'location': // Sort by location, with ungrouped printers last sorted.sort((a, b) => { const locA = a.location || ''; const locB = b.location || ''; if (!locA && locB) return 1; if (locA && !locB) return -1; return locA.localeCompare(locB) || a.name.localeCompare(b.name); }); break; case 'status': // Sort by status: HMS errors > printing > idle > offline sorted.sort((a, b) => { const statusA = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', a.id]); const statusB = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', b.id]); const getPriority = (s: typeof statusA) => { if (!s?.connected) return 3; // offline const hmsErrors = s.hms_errors ? filterKnownHMSErrors(s.hms_errors) : []; if (hmsErrors.length > 0) return 0; // HMS errors - top priority if (s.state === 'RUNNING') return 1; // printing return 2; // idle }; return getPriority(statusA) - getPriority(statusB); }); break; } // Apply ascending/descending if (!sortAsc) { sorted.reverse(); } return sorted; }, [filteredPrinters, sortBy, sortAsc, queryClient]); const selectAll = useCallback(() => { setSelectedPrinterIds(new Set(sortedPrinters.map(p => p.id))); setIsSelectionMode(true); }, [sortedPrinters]); const selectByState = useCallback((state: PrinterState) => { setSelectedPrinterIds(prev => { const next = new Set(prev); sortedPrinters.forEach(p => { const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]); if (classifyPrinterStatus(status) === state) next.add(p.id); }); return next; }); setIsSelectionMode(true); }, [sortedPrinters, queryClient]); const selectByLocation = useCallback((location: string) => { setSelectedPrinterIds(prev => { const next = new Set(prev); sortedPrinters.filter(p => (p.location || '') === location).forEach(p => next.add(p.id)); return next; }); setIsSelectionMode(true); }, [sortedPrinters]); const selectByModel = useCallback((model: string) => { setSelectedPrinterIds(prev => { const next = new Set(prev); sortedPrinters.filter(p => (p.model || 'Unknown') === model).forEach(p => next.add(p.id)); return next; }); setIsSelectionMode(true); }, [sortedPrinters]); const toggleSectionCollapse = useCallback((key: string) => { setCollapsedSections(prev => { const next = { ...prev, [key]: !prev[key] }; try { localStorage.setItem('printerCollapsedSections', JSON.stringify(next)); } catch { /* quota exceeded / private mode */ } return next; }); }, []); // Group printers when sorted by location, status, or model const groupedPrinters = useMemo(() => { if (sortBy === 'name') return null; const groups: Record = {}; if (sortBy === 'location') { sortedPrinters.forEach(printer => { const location = printer.location || 'Ungrouped'; if (!groups[location]) groups[location] = []; groups[location].push(printer); }); } else if (sortBy === 'model') { sortedPrinters.forEach(printer => { const model = printer.model || 'Unknown'; if (!groups[model]) groups[model] = []; groups[model].push(printer); }); } else if (sortBy === 'status') { sortedPrinters.forEach(printer => { const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', printer.id]); const group = classifyPrinterStatus(status); if (!groups[group]) groups[group] = []; groups[group].push(printer); }); } return groups; // eslint-disable-next-line react-hooks/exhaustive-deps -- classifyPrinterStatus & filterKnownHMSErrors are stable module-level functions, not reactive deps; statusCacheVersion forces recompute on WebSocket status updates }, [sortBy, sortedPrinters, queryClient, statusCacheVersion]); const toolbarRef = useRef(null); const expandedToolbarControlsRef = useRef(null); const expandedToolbarWidthRef = useRef(0); const [compactToolbar, setCompactToolbar] = useState(false); const measureToolbar = useCallback(() => { const toolbar = toolbarRef.current; if (!toolbar) return; const measuredControlsWidth = expandedToolbarControlsRef.current?.offsetWidth; if (measuredControlsWidth) { expandedToolbarWidthRef.current = measuredControlsWidth; } const searchMinimumWidth = 220; const gapWidth = 8; const shouldCompact = expandedToolbarWidthRef.current > 0 && toolbar.clientWidth < expandedToolbarWidthRef.current + searchMinimumWidth + gapWidth; setCompactToolbar(prev => (prev === shouldCompact ? prev : shouldCompact)); }, []); const smartPlugCount = Object.keys(smartPlugByPrinter).length; useLayoutEffect(() => { measureToolbar(); const toolbar = toolbarRef.current; if (!toolbar) return; if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', measureToolbar); return () => window.removeEventListener('resize', measureToolbar); } const resizeObserver = new ResizeObserver(() => measureToolbar()); resizeObserver.observe(toolbar); window.addEventListener('resize', measureToolbar); return () => { resizeObserver.disconnect(); window.removeEventListener('resize', measureToolbar); }; }, [ measureToolbar, printers?.length, availableLocations.length, hideDisconnected, smartPlugCount, ]); const renderFilterControls = (inMenu = false) => ( <> {/* Status filter */} {printers && printers.length > 0 && ( )} {/* Location filter — only shown when at least one printer has a location */} {printers && printers.length > 0 && availableLocations.length > 0 && ( ({ value: loc, label: loc })), ]} /> )} ); const renderViewControls = (inMenu = false) => ( <> {/* Sort dropdown */}
value={sortBy} onChange={handleSortChange} fullWidth={inMenu} options={[ { value: 'name', label: t('printers.sort.name') }, { value: 'status', label: t('printers.sort.status') }, { value: 'model', label: t('printers.sort.model') }, { value: 'location', label: t('printers.sort.location') }, ]} />
{/* Card size selector */}
{cardSizeLabels.map((label, index) => { const size = index + 1; const isSelected = cardSize === size; return ( ); })}
); const renderActionControls = (inMenu = false) => ( <> {/* Bulk select toggle */} {/* Power dropdown for offline printers with smart plugs */} {hideDisconnected && Object.keys(smartPlugByPrinter).length > 0 && (
{showPowerDropdown && ( <> {/* Backdrop to close dropdown */}
setShowPowerDropdown(false)} />
{t('printers.offlinePrintersWithPlugs')}
{printers?.filter(p => smartPlugByPrinter[p.id]).map(printer => ( { setPoweringOn(plugId); powerOnMutation.mutate(plugId); }} isPowering={poweringOn === smartPlugByPrinter[printer.id]?.id} /> ))} {printers?.filter(p => smartPlugByPrinter[p.id]).length === 0 && (
No printers with smart plugs
)}
)}
)} ); return (

{t('printers.title')}

{/* Only show search bar when printers exist */} {printers && printers.length > 0 && (
setSearch(e.target.value)} placeholder={t('printers.search')} aria-label={t('printers.search')} className="w-full h-8 pl-9 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green" /> {search && ( )}
)}
*]:shrink-0`} >
{renderFilterControls()}
{renderViewControls()}
{renderActionControls()}
{compactToolbar && (
}>
{renderFilterControls(true)}
}>
{renderViewControls(true)}
}>
{renderActionControls(true)}
)}
{isLoading ? (
{t('common.loading')}
) : printers?.length === 0 ? (

{t('printers.noPrintersConfigured')}

) : sortedPrinters.length === 0 && (search.trim() || statusFilter !== 'all' || locationFilter !== 'all') ? (

{t('printers.noSearchResults')}

) : groupedPrinters ? ( /* Grouped view (location, status, or model) */
{(() => { const keys = sortBy === 'status' ? STATUS_GROUP_ORDER.filter(k => groupedPrinters[k]?.length > 0) : Object.keys(groupedPrinters); // For status grouping, asc/desc flips the fixed priority order // (asc = error→offline, desc = offline→error). This matches the // sort-toggle behaviour for other groupings. return (sortAsc ? keys : [...keys].reverse()); })().map((groupKey) => { const groupPrinters = groupedPrinters[groupKey]; const collapseKey = `${sortBy}:${groupKey}`; const isOpen = !collapsedSections[collapseKey]; const dot = sortBy === 'status' ? STATUS_GROUP_META[groupKey]?.dot || 'bg-bambu-green' : 'bg-bambu-green'; const label = sortBy === 'status' ? t(STATUS_GROUP_META[groupKey]?.labelKey || groupKey) : groupKey; return ( toggleSectionCollapse(collapseKey)} summaryClassName="py-1" summary={

{label} ({groupPrinters.length}) {selectionMode && ( )}

} >
= 3 ? 'gap-6' : ''} ${getGridClasses()}`}> {groupPrinters.map((printer) => ( unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })} spoolmanSpools={spoolmanSpools} spoolmanSlotAssignments={spoolmanSlotAssignments} spoolmanLoading={spoolmanSpoolsLoading || spoolmanAssignmentsLoading} onUnassignSpoolmanSpool={(id) => unassignSpoolmanMutation.mutate(id)} timeFormat={settings?.time_format || 'system'} cameraViewMode={settings?.camera_view_mode || 'window'} onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))} checkPrinterFirmware={settings?.check_printer_firmware !== false} dryingPresets={effectiveDryingPresets} requirePlateClear={settings?.require_plate_clear === true} selectionMode={selectionMode} isSelected={selectedPrinterIds.has(printer.id)} onToggleSelect={toggleSelect} /> ))}
); })}
) : ( /* Regular grid view */
= 3 ? 'gap-6' : ''} ${getGridClasses()}`}> {sortedPrinters.map((printer) => ( unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })} spoolmanSpools={spoolmanSpools} spoolmanSlotAssignments={spoolmanSlotAssignments} spoolmanLoading={spoolmanSpoolsLoading || spoolmanAssignmentsLoading} onUnassignSpoolmanSpool={(id) => unassignSpoolmanMutation.mutate(id)} amsThresholds={settings ? { humidityGood: Number(settings.ams_humidity_good) || 40, humidityFair: Number(settings.ams_humidity_fair) || 60, tempGood: Number(settings.ams_temp_good) || 28, tempFair: Number(settings.ams_temp_fair) || 35, } : undefined} timeFormat={settings?.time_format || 'system'} cameraViewMode={settings?.camera_view_mode || 'window'} onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))} checkPrinterFirmware={settings?.check_printer_firmware !== false} dryingPresets={effectiveDryingPresets} requirePlateClear={settings?.require_plate_clear === true} selectionMode={selectionMode} isSelected={selectedPrinterIds.has(printer.id)} onToggleSelect={toggleSelect} /> ))}
)} {showAddModal && ( setShowAddModal(false)} onAdd={(data) => addMutation.mutate(data)} existingSerials={printers?.map(p => p.serial_number) || []} /> )} {/* Bulk selection toolbar */} {selectionMode && printers && ( )} {/* Bulk action confirmation modals */} {bulkConfirmAction === 'stop' && ( executeBulkAction('stop')} onCancel={() => setBulkConfirmAction(null)} /> )} {bulkConfirmAction === 'pause' && ( executeBulkAction('pause')} onCancel={() => setBulkConfirmAction(null)} /> )} {bulkConfirmAction === 'clearPlate' && ( executeBulkAction('clearPlate')} onCancel={() => setBulkConfirmAction(null)} /> )} {/* Embedded Camera Viewers - multiple viewers can be open simultaneously */} {Array.from(embeddedCameraPrinters.values()).map((camera, index) => ( setEmbeddedCameraPrinters(prev => { const next = new Map(prev); next.delete(camera.id); return next; })} /> ))}
); }