import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; 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, Box, HardDrive, AlertTriangle, AlertCircle, Terminal, Power, PowerOff, Zap, Wrench, ChevronDown, Pencil, ArrowUp, ArrowDown, Layers, Video, Search, Loader2, Square, Pause, Play, X, Fan, Wind, AirVent, Download, ScanSearch, CheckCircle, XCircle, User, Home, Printer as PrinterIcon, Info, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { api, discoveryApi, firmwareApi } from '../api/client'; import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date'; import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; 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 { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal'; import { FileUploadModal } from '../components/FileUploadModal'; import { PrintModal } from '../components/PrintModal'; import { PrinterInfoModal } from '../components/PrinterInfoModal'; import { getGlobalTrayId } from '../utils/amsHelpers'; import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer'; import { FilamentSlotCircle } from '../components/FilamentSlotCircle'; import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors'; // Complete Bambu Lab filament color mapping by tray_id_name // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library const BAMBU_FILAMENT_COLORS: Record = { // PLA Basic (A00) 'A00-W1': 'Jade White', 'A00-P0': 'Beige', 'A00-D2': 'Light Gray', 'A00-Y0': 'Yellow', 'A00-Y2': 'Sunflower Yellow', 'A00-A1': 'Pumpkin Orange', 'A00-A0': 'Orange', 'A00-Y4': 'Gold', 'A00-G3': 'Bright Green', 'A00-G1': 'Bambu Green', 'A00-G2': 'Mistletoe Green', 'A00-R3': 'Hot Pink', 'A00-P6': 'Magenta', 'A00-R0': 'Red', 'A00-R2': 'Maroon Red', 'A00-P5': 'Purple', 'A00-P2': 'Indigo Purple', 'A00-B5': 'Turquoise', 'A00-B8': 'Cyan', 'A00-B3': 'Cobalt Blue', 'A00-N0': 'Brown', 'A00-N1': 'Cocoa Brown', 'A00-Y3': 'Bronze', 'A00-D0': 'Gray', 'A00-D1': 'Silver', 'A00-B1': 'Blue Grey', 'A00-D3': 'Dark Gray', 'A00-K0': 'Black', // PLA Basic Gradient (A00-M*) 'A00-M3': 'Pink Citrus', 'A00-M6': 'Dusk Glare', 'A00-M0': 'Arctic Whisper', 'A00-M1': 'Solar Breeze', 'A00-M5': 'Blueberry Bubblegum', 'A00-M4': 'Mint Lime', 'A00-M2': 'Ocean to Meadow', 'A00-M7': 'Cotton Candy Cloud', // PLA Lite (A18) 'A18-K0': 'Black', 'A18-D0': 'Gray', 'A18-W0': 'White', 'A18-R0': 'Red', 'A18-Y0': 'Yellow', 'A18-B0': 'Cyan', 'A18-B1': 'Blue', 'A18-P0': 'Matte Beige', // PLA Matte (A01) 'A01-W2': 'Ivory White', 'A01-W3': 'Bone White', 'A01-Y2': 'Lemon Yellow', 'A01-A2': 'Mandarin Orange', 'A01-P3': 'Sakura Pink', 'A01-P4': 'Lilac Purple', 'A01-R3': 'Plum', 'A01-R1': 'Scarlet Red', 'A01-R4': 'Dark Red', 'A01-G0': 'Apple Green', 'A01-G1': 'Grass Green', 'A01-G7': 'Dark Green', 'A01-B4': 'Ice Blue', 'A01-B0': 'Sky Blue', 'A01-B3': 'Marine Blue', 'A01-B6': 'Dark Blue', 'A01-Y3': 'Desert Tan', 'A01-N1': 'Latte Brown', 'A01-N3': 'Caramel', 'A01-R2': 'Terracotta', 'A01-N2': 'Dark Brown', 'A01-N0': 'Dark Chocolate', 'A01-D3': 'Ash Gray', 'A01-D0': 'Nardo Gray', 'A01-K1': 'Charcoal', // PLA Glow (A12) 'A12-G0': 'Green', 'A12-R0': 'Pink', 'A12-A0': 'Orange', 'A12-Y0': 'Yellow', 'A12-B0': 'Blue', // PLA Marble (A07) 'A07-R5': 'Red Granite', 'A07-D4': 'White Marble', // PLA Aero (A11) 'A11-W0': 'White', 'A11-K0': 'Black', // PLA Sparkle (A08) 'A08-G3': 'Alpine Green Sparkle', 'A08-D5': 'Slate Gray Sparkle', 'A08-B7': 'Royal Purple Sparkle', 'A08-R2': 'Crimson Red Sparkle', 'A08-K2': 'Onyx Black Sparkle', 'A08-Y1': 'Classic Gold Sparkle', // PLA Metal (A02) 'A02-B2': 'Cobalt Blue Metallic', 'A02-G2': 'Oxide Green Metallic', 'A02-Y1': 'Iridium Gold Metallic', 'A02-D2': 'Iron Gray Metallic', // PLA Translucent (A17) 'A17-B1': 'Blue', 'A17-A0': 'Orange', 'A17-P0': 'Purple', // PLA Silk+ (A06) 'A06-Y1': 'Gold', 'A06-D0': 'Titan Gray', 'A06-D1': 'Silver', 'A06-W0': 'White', 'A06-R0': 'Candy Red', 'A06-G0': 'Candy Green', 'A06-G1': 'Mint', 'A06-B1': 'Blue', 'A06-B0': 'Baby Blue', 'A06-P0': 'Purple', 'A06-R1': 'Rose Gold', 'A06-R2': 'Pink', 'A06-Y0': 'Champagne', // PLA Silk Multi-Color (A05) 'A05-M8': 'Dawn Radiance', 'A05-M4': 'Aurora Purple', 'A05-M1': 'South Beach', 'A05-T3': 'Neon City', 'A05-T2': 'Midnight Blaze', 'A05-T1': 'Gilded Rose', 'A05-T4': 'Blue Hawaii', 'A05-T5': 'Velvet Eclipse', // PLA Galaxy (A15) 'A15-B0': 'Purple', 'A15-G0': 'Green', 'A15-G1': 'Nebulae', 'A15-R0': 'Brown', // PLA Wood (A16) 'A16-K0': 'Black Walnut', 'A16-R0': 'Rosewood', 'A16-N0': 'Clay Brown', 'A16-G0': 'Classic Birch', 'A16-W0': 'White Oak', 'A16-Y0': 'Ochre Yellow', // PLA-CF (A50) 'A50-D6': 'Lava Gray', 'A50-K0': 'Black', 'A50-B6': 'Royal Blue', // PLA Tough+ (A10) 'A10-W0': 'White', 'A10-D0': 'Gray', // PLA Tough (A09) 'A09-B5': 'Lavender Blue', 'A09-B4': 'Light Blue', 'A09-A0': 'Orange', 'A09-D1': 'Silver', 'A09-R3': 'Vermilion Red', 'A09-Y0': 'Yellow', // PETG HF (G02) 'G02-K0': 'Black', 'G02-W0': 'White', 'G02-R0': 'Red', 'G02-D0': 'Gray', 'G02-D1': 'Dark Gray', 'G02-Y1': 'Cream', 'G02-Y0': 'Yellow', 'G02-A0': 'Orange', 'G02-N1': 'Peanut Brown', 'G02-G1': 'Lime Green', 'G02-G0': 'Green', 'G02-G2': 'Forest Green', 'G02-B1': 'Lake Blue', 'G02-B0': 'Blue', // PETG Translucent (G01) 'G01-G1': 'Translucent Teal', 'G01-B0': 'Translucent Light Blue', 'G01-C0': 'Clear', 'G01-D0': 'Translucent Gray', 'G01-G0': 'Translucent Olive', 'G01-N0': 'Translucent Brown', 'G01-A0': 'Translucent Orange', 'G01-P1': 'Translucent Pink', 'G01-P0': 'Translucent Purple', // PETG-CF (G50) 'G50-P7': 'Violet Purple', 'G50-K0': 'Black', // ABS (B00) 'B00-D1': 'Silver', 'B00-K0': 'Black', 'B00-W0': 'White', 'B00-G6': 'Bambu Green', 'B00-G7': 'Olive', 'B00-Y1': 'Tangerine Yellow', 'B00-A0': 'Orange', 'B00-R0': 'Red', 'B00-B4': 'Azure', 'B00-B0': 'Blue', 'B00-B6': 'Navy Blue', // ABS-GF (B50) 'B50-A0': 'Orange', 'B50-K0': 'Black', // ASA (B01) 'B01-W0': 'White', 'B01-K0': 'Black', 'B01-D0': 'Gray', // ASA Aero (B02) 'B02-W0': 'White', // PC (C00) 'C00-C1': 'Transparent', 'C00-C0': 'Clear Black', 'C00-K0': 'Black', 'C00-W0': 'White', // PC FR (C01) 'C01-K0': 'Black', // TPU for AMS (U02) 'U02-B0': 'Blue', 'U02-D0': 'Gray', 'U02-K0': 'Black', // PAHT-CF (N04) 'N04-K0': 'Black', // PA6-GF (N08) 'N08-K0': 'Black', // Support for PLA/PETG (S02, S05) 'S02-W0': 'Nature', 'S02-W1': 'White', 'S05-C0': 'Black', // Support for ABS (S06) 'S06-W0': 'White', // Support for PA/PET (S03) 'S03-G1': 'Green', // PVA (S04) 'S04-Y0': 'Clear', }; // Fallback color codes for unknown material prefixes const BAMBU_COLOR_CODE_FALLBACK: Record = { 'W0': 'White', 'W1': 'Jade White', 'W2': 'Ivory White', 'W3': 'Bone White', 'Y0': 'Yellow', 'Y1': 'Gold', 'Y2': 'Sunflower Yellow', 'Y3': 'Bronze', 'Y4': 'Gold', 'A0': 'Orange', 'A1': 'Pumpkin Orange', 'A2': 'Mandarin Orange', 'R0': 'Red', 'R1': 'Scarlet Red', 'R2': 'Maroon Red', 'R3': 'Hot Pink', 'R4': 'Dark Red', 'R5': 'Red Granite', 'P0': 'Beige', 'P1': 'Pink', 'P2': 'Indigo Purple', 'P3': 'Sakura Pink', 'P4': 'Lilac Purple', 'P5': 'Purple', 'P6': 'Magenta', 'P7': 'Violet Purple', 'B0': 'Blue', 'B1': 'Blue Grey', 'B2': 'Cobalt Blue', 'B3': 'Cobalt Blue', 'B4': 'Ice Blue', 'B5': 'Turquoise', 'B6': 'Navy Blue', 'B7': 'Royal Purple', 'B8': 'Cyan', 'G0': 'Green', 'G1': 'Grass Green', 'G2': 'Mistletoe Green', 'G3': 'Bright Green', 'G6': 'Bambu Green', 'G7': 'Dark Green', 'N0': 'Brown', 'N1': 'Peanut Brown', 'N2': 'Dark Brown', 'N3': 'Caramel', 'D0': 'Gray', 'D1': 'Silver', 'D2': 'Light Gray', 'D3': 'Dark Gray', 'D4': 'White Marble', 'D5': 'Slate Gray', 'D6': 'Lava Gray', 'K0': 'Black', 'K1': 'Charcoal', 'K2': 'Onyx Black', 'C0': 'Clear Black', 'C1': 'Transparent', 'M0': 'Arctic Whisper', 'M1': 'Solar Breeze', 'M2': 'Ocean to Meadow', 'M3': 'Pink Citrus', 'M4': 'Aurora Purple', 'M5': 'Blueberry Bubblegum', 'M6': 'Dusk Glare', 'M7': 'Cotton Candy Cloud', 'M8': 'Dawn Radiance', 'T1': 'Gilded Rose', 'T2': 'Midnight Blaze', 'T3': 'Neon City', 'T4': 'Blue Hawaii', 'T5': 'Velvet Eclipse', }; // Get color name from Bambu Lab tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow") function getBambuColorName(trayIdName: string | null | undefined): string | null { if (!trayIdName) return null; // First try exact match with full tray_id_name if (BAMBU_FILAMENT_COLORS[trayIdName]) { return BAMBU_FILAMENT_COLORS[trayIdName]; } // Fall back to color code suffix lookup for unknown material prefixes const parts = trayIdName.split('-'); if (parts.length < 2) return null; const colorCode = parts[1]; return BAMBU_COLOR_CODE_FALLBACK[colorCode] || null; } // 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 IDs are 16-21 — map by actual ID so empty slots appear in the correct position const rackNozzles = slots.filter(s => s.id >= 2); const RACK_SIZE = 6; const minRackId = rackNozzles.length > 0 ? Math.min(...rackNozzles.map(s => s.id)) : 16; const rackSlots: (import('../api/client').NozzleRackSlot)[] = Array.from( { length: RACK_SIZE }, (_, i) => rackNozzles.find(s => s.id === minRackId + 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 ( ); } // 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 ( ); } // Get AMS label: AMS-A/B/C/D for regular AMS, HT-A/B for AMS-HT (single spool) // Always use tray count as the source of truth (1 tray = AMS-HT, 4 trays = regular AMS) // AMS-HT uses IDs 128+ while regular AMS uses 0-3 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}`; } // Get fill bar color based on spool fill level function getFillBarColor(fillLevel: number): string { if (fillLevel > 50) return '#00ae42'; // Green - good if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%) return '#ef4444'; // Red - critical (< 15%) } // Calculate fill level from Spoolman weight data (used as fallback when AMS reports 0%) function getSpoolmanFillLevel( linkedSpool: LinkedSpoolInfo | undefined ): number | null { if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight || linkedSpool.filament_weight <= 0) return null; return Math.min(100, Math.round( (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100 )); } /** * Check if a tray contains a Bambu Lab spool (RFID-tagged). * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx, * which is a filament profile/preset ID that third-party spools also get when * the user selects a generic Bambu preset (e.g. "GFA00" for Generic PLA). */ function isBambuLabSpool(tray: { tray_uuid?: string | null; tag_uid?: string | null; } | null | undefined): boolean { if (!tray) return false; // Check tray_uuid (32 hex chars, non-zero) if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') { return true; } // Check tag_uid (16 hex chars, non-zero) if (tray.tag_uid && tray.tag_uid !== '0000000000000000') { return true; } return false; } 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 `${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 idle = 0; let offline = 0; let loading = 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 }>(['printerStatus', printer.id]); if (status === undefined) { // Status not yet loaded - don't count as offline yet loading++; } else if (!status.connected) { offline++; } else if (status.state === '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; } } } else { idle++; } }); return { counts: { printing, idle, offline, loading, 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; return (
0 ? 'bg-bambu-green' : 'bg-gray-500'}`} /> {counts.idle} {t('printers.status.available').toLowerCase()}
{counts.printing > 0 && (
{counts.printing} {t('printers.status.printing').toLowerCase()}
)} {counts.offline > 0 && (
{counts.offline} {t('printers.status.offline').toLowerCase()}
)} {nextFinish && ( <>
{t('printers.nextAvailable')}: {nextFinish.name}
{Math.round(nextFinish.progress)}% ({formatDuration(nextFinish.remainingMin * 60)})
)}
); } type SortOption = 'name' | 'status' | 'model' | 'location'; type ViewMode = 'expanded' | 'compact'; /** * 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', // P Series 'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S', // A1 Series 'N2S': 'A1', 'N1': 'A1 Mini', // Direct matches 'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', '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] && ( )}
)}
)}
); } function PrinterCard({ printer, hideIfDisconnected, maintenanceInfo, viewMode = 'expanded', cardSize = 2, amsThresholds, spoolmanEnabled = false, hasUnlinkedSpools = false, linkedSpools, spoolmanUrl, onGetAssignment, onUnassignSpool, timeFormat = 'system', cameraViewMode = 'window', onOpenEmbeddedCamera, checkPrinterFirmware = true, }: { 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; spoolAssignments?: SpoolAssignment[]; onGetAssignment?: (printerId: number, amsId: number, trayId: number) => SpoolAssignment | undefined; onUnassignSpool?: (printerId: number, amsId: number, trayId: number) => void; timeFormat?: 'system' | '12h' | '24h'; cameraViewMode?: 'window' | 'embedded'; onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void; checkPrinterFirmware?: boolean; }) { 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 [showHMSModal, setShowHMSModal] = useState(false); const [showStopConfirm, setShowStopConfirm] = useState(false); const [showPauseConfirm, setShowPauseConfirm] = useState(false); const [showResumeConfirm, setShowResumeConfirm] = useState(false); const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false); const [showUploadForPrint, setShowUploadForPrint] = useState(false); const [showPrinterInfo, setShowPrinterInfo] = useState(false); const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []); const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | 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 }; } | 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 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]; // 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] }); }, }); // 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 script mutation const runScriptMutation = useMutation({ mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'), 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'), }); // 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'); }, }); // 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); }, }); // 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 ( {/* 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' && (
)}

{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')} {/* WiFi signal indicator */} {status?.connected && 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}
)}
{/* 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)}%
) : (

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

)}
) : ( /* 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)}

{status.subtask_name || status.current_print}

{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)}

{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) && ( )}
); })()} {/* 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}%
{/* 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 /> )}
)}
{/* 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; // 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?.toUpperCase(); const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined; const spoolmanFill = getSpoolmanFillLevel(linkedSpool); 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; })(); const effectiveFill = spoolmanFill ?? inventoryFill ?? (hasFillLevel ? tray.remain : null); const fillSource = spoolmanFill !== null ? 'spoolman' as const : inventoryFill !== 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: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type, colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(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 || '—'}
{/* Fill bar */}
{effectiveFill !== null && effectiveFill >= 0 && 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 ? ( { setLinkSpoolModal({ tagUid: filamentData.tagUid || '', trayUuid: uuid, printerId: printer.id, amsId: ams.id, trayId: slotIdx, }); } : undefined, }} inventory={spoolmanEnabled ? undefined : (() => { 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: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({ printerId: printer.id, amsId: ams.id, trayId: slotIdx, trayInfo: { type: filamentData.profile, color: filamentData.colorHex || '', location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`, }, }) : undefined, onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined, }; })()} 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, }), }} > {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; // 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]; // Fill level fallback chain: Spoolman → Inventory → AMS remain const htTrayTag = tray?.tray_uuid?.toUpperCase(); const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined; const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool); const htTraySlotId = tray?.id ?? 0; const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htTraySlotId); 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; })(); const htEffectiveFill = htSpoolmanFill ?? htInventoryFill ?? (hasFillLevel ? tray.remain : null); const htFillSource = htSpoolmanFill !== null ? 'spoolman' as const : htInventoryFill !== 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: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type, colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(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; const htSlotId = tray?.id ?? 0; // 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 || '—'}
{/* Fill bar */}
{htEffectiveFill !== null && htEffectiveFill >= 0 && (
)}
); return (
{/* Row 1: Label + Nozzle */}
{/* AMS name — hover to see serial, firmware, and edit friendly name */} {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)} {isDualNozzle && (isLeftNozzle || isRightNozzle) && ( )}
{/* 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 ? ( { setLinkSpoolModal({ tagUid: filamentData.tagUid || '', trayUuid: uuid, printerId: printer.id, amsId: ams.id, trayId: htSlotId, }); } : undefined, }} inventory={spoolmanEnabled ? undefined : (() => { 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: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({ printerId: printer.id, amsId: ams.id, trayId: htSlotId, trayInfo: { type: filamentData.profile, color: filamentData.colorHex || '', location: getAmsLabel(ams.id, ams.tray.length), }, }) : undefined, onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined, }; })()} 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, }), }} > {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?.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; const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? (extHasFillLevel ? extTray.remain : null); const extFillSource = extSpoolmanFill !== null ? 'spoolman' as const : extInventoryFill !== null ? 'inventory' as const : extHasFillLevel ? 'ams' as const : undefined; const extFilamentData = { vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown', colorName: getBambuColorName(extTray.tray_id_name) || hexToColorName(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 extSlotContent = (
{/* Filament color circle with 1-based slot number centered inside */}
{extTray.tray_type || '—'}
{extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty && (
)}
{extLabel &&
{extLabel}
}
); return (
{!isEmpty ? ( { setLinkSpoolModal({ tagUid: extFilamentData.tagUid || '', trayUuid: uuid, printerId: printer.id, amsId: 255, trayId: slotTrayId, }); } : undefined, }} inventory={spoolmanEnabled ? undefined : (() => { 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: extFilamentData.profile, color: extFilamentData.colorHex || '', location: extLabel || t('printers.external'), }, }), onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, slotTrayId) : undefined, }; })()} 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, }), }} > {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 && ( · {plugStatus.energy.power}W )} )}
{/* Spacer */}
{/* Power buttons */}
{/* Auto-off toggle */}
{/* HA entity buttons row */} {scriptPlugs && scriptPlugs.length > 0 && (
HA:
{scriptPlugs.map(script => ( ))}
)}
)} {/* 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)} /> )} {/* MQTT Debug Modal */} {showMQTTDebug && ( setShowMQTTDebug(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)} /> )} {/* 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)} /> )} {/* 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} /> )} {/* 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)} /> )} ); } 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 }); // 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 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')}

)}
{ e.preventDefault(); onAdd(form); }} className="space-y-4" >
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" />
); } 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); // Prepare check query (only when update available and user can update) const { data: prepareInfo, isLoading: isPreparing } = useQuery({ queryKey: ['firmwarePrepare', printer.id], queryFn: () => firmwareApi.prepareUpload(printer.id), staleTime: 30000, enabled: firmwareInfo.update_available && canUpdate, }); // Start upload mutation const uploadMutation = useMutation({ mutationFn: () => firmwareApi.startUpload(printer.id), 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 */}
{t('printers.firmwareModal.currentVersion')} {firmwareInfo.current_version || t('common.unknown')}
{firmwareInfo.update_available && (
{t('printers.firmwareModal.latestVersion')} {firmwareInfo.latest_version}
)} {firmwareInfo.release_notes && (
{t('printers.firmwareModal.releaseNotes')}
{firmwareInfo.release_notes}
)}
{/* Status / Progress (only when update available) */} {!firmwareInfo.update_available ? 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, }); 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 handleSubmit = (e: React.FormEvent) => { e.preventDefault(); 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); }; 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" />
); } // 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 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 app settings for AMS thresholds const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); // 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 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'] }); }, }); // 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) => 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); }, }); 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']; // Sort printers based on selected option const sortedPrinters = useMemo(() => { if (!printers) return []; const sorted = [...printers]; 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: printing > idle > offline sorted.sort((a, b) => { const statusA = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', a.id]); const statusB = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', b.id]); const getPriority = (s: typeof statusA) => { if (!s?.connected) return 2; // offline if (s.state === 'RUNNING') return 0; // printing return 1; // idle }; return getPriority(statusA) - getPriority(statusB); }); break; } // Apply ascending/descending if (!sortAsc) { sorted.reverse(); } return sorted; }, [printers, sortBy, sortAsc, queryClient]); // Group printers by location when sorted by location const groupedPrinters = useMemo(() => { if (sortBy !== 'location') return null; const groups: Record = {}; sortedPrinters.forEach(printer => { const location = printer.location || 'Ungrouped'; if (!groups[location]) groups[location] = []; groups[location].push(printer); }); return groups; }, [sortBy, sortedPrinters]); return (

{t('printers.title')}

{/* Sort dropdown */}
{/* Card size selector */}
{cardSizeLabels.map((label, index) => { const size = index + 1; const isSelected = cardSize === size; return ( ); })}
{/* 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
)}
)}
)}
{isLoading ? (
{t('common.loading')}
) : printers?.length === 0 ? (

{t('printers.noPrintersConfigured')}

) : groupedPrinters ? ( /* Grouped by location view */
{Object.entries(groupedPrinters).map(([location, locationPrinters]) => (

{location} ({locationPrinters.length})

= 3 ? 'gap-6' : ''} ${getGridClasses()}`}> {locationPrinters.map((printer) => ( unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })} 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} /> ))}
))}
) : ( /* Regular grid view */
= 3 ? 'gap-6' : ''} ${getGridClasses()}`}> {sortedPrinters.map((printer) => ( unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })} 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} /> ))}
)} {showAddModal && ( setShowAddModal(false)} onAdd={(data) => addMutation.mutate(data)} existingSerials={printers?.map(p => p.serial_number) || []} /> )} {/* 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; })} /> ))}
); }