import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTheme } from '../contexts/ThemeContext'; 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, Monitor, Fan, Wind, AirVent, Download, ScanSearch, CheckCircle, XCircle, } from 'lucide-react'; // Custom Skip Objects icon - arrow jumping over boxes const SkipObjectsIcon = ({ className }: { className?: string }) => ( {/* Three boxes at the bottom */} {/* Curved arrow jumping over first box */} ); import { useNavigate } from 'react-router-dom'; import { api, discoveryApi, firmwareApi } from '../api/client'; import { formatDateOnly } from '../utils/date'; import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus } 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 { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal'; import { useToast } from '../contexts/ToastContext'; import { ChamberLight } from '../components/icons/ChamberLight'; // 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; } // Convert hex color to basic color name function hexToBasicColorName(hex: string | null | undefined): string { if (!hex || hex.length < 6) return 'Unknown'; // Parse RGB from hex (format: RRGGBBAA or RRGGBB) const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); // Calculate HSL for better color classification const max = Math.max(r, g, b) / 255; const min = Math.min(r, g, b) / 255; const l = (max + min) / 2; let h = 0; let s = 0; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); const rNorm = r / 255; const gNorm = g / 255; const bNorm = b / 255; if (max === rNorm) { h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6; } else if (max === gNorm) { h = ((bNorm - rNorm) / d + 2) / 6; } else { h = ((rNorm - gNorm) / d + 4) / 6; } } // Convert to degrees h = h * 360; // Classify by lightness first if (l < 0.15) return 'Black'; if (l > 0.85) return 'White'; // Low saturation = gray if (s < 0.15) { if (l < 0.4) return 'Dark Gray'; if (l > 0.6) return 'Light Gray'; return 'Gray'; } // Classify by hue // Brown is orange/yellow hue with lower lightness if (h >= 15 && h < 45 && l < 0.45) return 'Brown'; if (h >= 45 && h < 70 && l < 0.40) return 'Brown'; if (h < 15 || h >= 345) return 'Red'; if (h < 45) return 'Orange'; if (h < 70) return 'Yellow'; if (h < 150) return 'Green'; if (h < 200) return 'Cyan'; if (h < 260) return 'Blue'; if (h < 290) return 'Purple'; if (h < 345) return 'Pink'; return 'Unknown'; } // 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} ); } // 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%) } function formatTime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } function formatETA(remainingMinutes: number, timeFormat: 'system' | '12h' | '24h' = 'system'): string { const now = new Date(); const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000); const today = new Date(); today.setHours(0, 0, 0, 0); const etaDay = new Date(eta); etaDay.setHours(0, 0, 0, 0); // Build time format options based on setting const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }; if (timeFormat === '12h') { timeOptions.hour12 = true; } else if (timeFormat === '24h') { timeOptions.hour12 = false; } // 'system' leaves hour12 undefined, letting the browser decide const timeStr = eta.toLocaleTimeString([], timeOptions); // Check if it's tomorrow or later const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); if (dayDiff === 0) { return timeStr; } else if (dayDiff === 1) { return `Tomorrow ${timeStr}`; } else { return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr; } } function getPrinterImage(model: string | null | undefined): string { if (!model) return '/img/printers/default.png'; const modelLower = model.toLowerCase().replace(/\s+/g, ''); // Map model names to image files if (modelLower.includes('x1e')) return '/img/printers/x1e.png'; if (modelLower.includes('x1c') || modelLower.includes('x1carbon')) return '/img/printers/x1c.png'; if (modelLower.includes('x1')) return '/img/printers/x1c.png'; if (modelLower.includes('h2d')) return '/img/printers/h2d.png'; if (modelLower.includes('h2c') || modelLower.includes('h2s')) return '/img/printers/h2d.png'; if (modelLower.includes('p2s')) return '/img/printers/p1s.png'; if (modelLower.includes('p1s')) return '/img/printers/p1s.png'; if (modelLower.includes('p1p')) return '/img/printers/p1p.png'; if (modelLower.includes('a1mini')) return '/img/printers/a1mini.png'; if (modelLower.includes('a1')) return '/img/printers/a1.png'; return '/img/printers/default.png'; } function getWifiStrength(rssi: number | null | undefined): { label: string; color: string; bars: number } { if (rssi == null) return { label: '', color: 'text-bambu-gray', bars: 0 }; if (rssi >= -50) return { label: 'Excellent', color: 'text-bambu-green', bars: 4 }; if (rssi >= -60) return { label: 'Good', color: 'text-bambu-green', bars: 3 }; if (rssi >= -70) return { label: 'Fair', color: 'text-yellow-400', bars: 2 }; if (rssi >= -80) return { label: 'Weak', color: 'text-orange-400', bars: 1 }; return { label: 'Very weak', color: 'text-red-400', bars: 1 }; } /** * Check if a tray contains a Bambu Lab spool. * Uses same logic as backend: tray_info_idx (GF*), tray_uuid, or tag_uid. */ function isBambuLabSpool(tray: { tray_uuid?: string | null; tag_uid?: string | null; tray_info_idx?: string | null; } | null | undefined): boolean { if (!tray) return false; // Check tray_info_idx first (most reliable - Bambu preset IDs start with "GF") if (tray.tray_info_idx && tray.tray_info_idx.startsWith('GF')) { return true; } // 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 [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); const [showOverlay, setShowOverlay] = useState(false); return ( <>
url && loaded && setShowOverlay(true)} > {url && !error ? ( <> Print preview setLoaded(true)} onError={() => setError(true)} /> {!loaded && } ) : ( )}
{/* Cover Image Overlay */} {showOverlay && url && (
setShowOverlay(false)} >
Print preview {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 queryClient = useQueryClient(); const counts = useMemo(() => { let printing = 0; let idle = 0; let offline = 0; let loading = 0; printers?.forEach((printer) => { const status = queryClient.getQueryData<{ connected: boolean; state: string | 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++; } else { idle++; } }); return { printing, idle, offline, loading, total: (printers?.length || 0) }; }, [printers, queryClient]); // Subscribe to query cache changes to re-render when status updates // Throttled to prevent rapid re-renders from causing tab crashes const [, setTick] = useState(0); useEffect(() => { let pending = false; const unsubscribe = queryClient.getQueryCache().subscribe(() => { if (!pending) { pending = true; requestAnimationFrame(() => { setTick(t => t + 1); pending = false; }); } }); return () => unsubscribe(); }, [queryClient]); if (!printers?.length) return null; return (
{counts.printing > 0 && (
{counts.printing} printing
)} {counts.idle > 0 && (
{counts.idle} idle
)} {counts.offline > 0 && (
{counts.offline} offline
)}
); } 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'; } } function PrinterCard({ printer, hideIfDisconnected, maintenanceInfo, viewMode = 'expanded', cardSize = 2, amsThresholds, spoolmanEnabled = false, hasUnlinkedSpools = false, 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; timeFormat?: 'system' | '12h' | '24h'; cameraViewMode?: 'window' | 'embedded'; onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void; checkPrinterFirmware?: boolean; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); const { showToast } = useToast(); 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 [amsHistoryModal, setAmsHistoryModal] = useState<{ amsId: number; amsLabel: string; mode: 'humidity' | 'temperature'; } | null>(null); const [linkSpoolModal, setLinkSpoolModal] = useState<{ trayUuid: string; 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; } | 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, }); // 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); } } } } if (status?.vt_tray?.tray_info_idx) { ids.add(status.vt_tray.tray_info_idx); } return Array.from(ids); }, [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 }); // 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 255 (unloaded) or undefined values come in // Only update cache when we get a valid tray ID (0-253 or 254 for external) const cachedTrayNow = useRef(255); const currentTrayNow = status?.tray_now; // Update cache synchronously during render if we have a valid value if (currentTrayNow !== undefined && currentTrayNow !== 255) { cachedTrayNow.current = currentTrayNow; } // Use cached value if current is 255/undefined but we had a valid value before const effectiveTrayNow = (currentTrayNow === undefined || currentTrayNow === 255) ? cachedTrayNow.current : currentTrayNow; // Fetch smart plug for this printer const { data: smartPlug } = useQuery({ queryKey: ['smartPlugByPrinter', printer.id], queryFn: () => api.getSmartPlugByPrinter(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'), }); const queueCount = queueItems?.length || 0; // 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'] }); }, }); 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'] }); }, }); // Print control mutations const stopPrintMutation = useMutation({ mutationFn: () => api.stopPrint(printer.id), onSuccess: () => { showToast('Print stopped'); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, onError: (error: Error) => showToast(error.message || 'Failed to stop print', 'error'), }); const pausePrintMutation = useMutation({ mutationFn: () => api.pausePrint(printer.id), onSuccess: () => { showToast('Print paused'); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, onError: (error: Error) => showToast(error.message || 'Failed to pause print', 'error'), }); const resumePrintMutation = useMutation({ mutationFn: () => api.resumePrint(printer.id), onSuccess: () => { showToast('Print resumed'); queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); }, onError: (error: Error) => showToast(error.message || 'Failed to resume print', '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 || 'Failed to control chamber light', '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 ? 'Plate check enabled' : 'Plate check disabled'); }, onError: (error: Error) => showToast(error.message || 'Failed to update setting', '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?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2; const { data: objectsData, refetch: refetchObjects } = 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 }); // Skip objects mutation const skipObjectsMutation = useMutation({ mutationFn: (objectIds: number[]) => api.skipObjects(printer.id, objectIds), onSuccess: (data) => { showToast(data.message || 'Objects skipped'); refetchObjects(); }, onError: (error: Error) => showToast(error.message || 'Failed to skip objects', 'error'), }); // 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 || 'RFID re-read initiated'); }, onError: (error: Error) => { showToast(error.message || 'Failed to re-read RFID', '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 : 'Failed to check plate', '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 || 'Calibration saved!', 'success'); // Refresh references and re-check fetchPlateReferences(); const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true }); setPlateCheckResult(checkResult); } else { showToast(result.message || 'Calibration failed', 'error'); } } catch (error) { showToast(error instanceof Error ? error.message : 'Calibration failed', '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 : 'Failed to update label', 'error'); } }; // Delete reference const handleDeleteRef = async (index: number) => { try { await api.deletePlateReference(printer.id, index); showToast('Reference deleted', '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 : 'Failed to delete reference', 'error'); } }; // Save ROI settings const handleSaveRoi = async () => { if (!editingRoi) return; setIsSavingRoi(true); try { await api.updatePrinter(printer.id, { plate_detection_roi: editingRoi }); showToast('Detection area saved', '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 : 'Failed to save detection area', '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'; } }; return ( = 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 ? 'Connected' : 'Offline'} {/* WiFi signal strength 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 - ${getWifiStrength(wifiSignal).label}`} > {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 Update Badge */} {firmwareInfo?.update_available && ( )}
)}
{/* Delete Confirmation */} {showDeleteConfirm && (

Delete Printer

Are you sure you want to delete "{printer.name}"? This will remove all connection settings.

)} {/* Status */} {status?.connected && ( <> {/* Compact: Simple status bar */} {viewMode === 'compact' ? (
{status.state === 'RUNNING' ? (
{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.stg_cur_name || 'Printing'}

{status.subtask_name || status.current_print}

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

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

) : (

Ready to print

)} )}
{/* Queue Widget - shows next scheduled print */} {status.state !== 'RUNNING' && ( )} )} {/* 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'; 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)}°

) : ( <>

Nozzle

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

)}

Bed

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

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

Chamber

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

)} {/* Active nozzle indicator for dual-nozzle printers */} {isDualNozzle && (

L

Nozzle

R

)}
); })()} {/* Controls - Fans + Print Buttons */} {viewMode === 'expanded' && (() => { // Determine print state for control buttons const isRunning = status.state === 'RUNNING'; const isPaused = status.state === 'PAUSED' || 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 */}
Controls
{/* Left: Fan Status - always visible, dynamic coloring */}
{/* Part Cooling Fan */}
0 ? 'bg-cyan-500/10' : 'bg-bambu-dark'}`} title="Part Cooling Fan" > 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="Auxiliary Fan" > 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="Chamber Fan" > 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 && amsData.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 */}
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) */}
{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]; // 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) || hexToBasicColorName(tray.tray_color), colorHex: tray.tray_color || null, kFactor: formatKValue(tray.k), fillLevel: hasFillLevel ? tray.remain : null, trayUuid: tray.tray_uuid || null, } : 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 = (
{tray?.tray_type || '—'}
{/* Fill bar */}
{hasFillLevel && tray ? (
) : tray?.tray_type ? (
) : null}
); // 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({ trayUuid: uuid, trayInfo: { type: filamentData.profile, color: filamentData.colorHex || '', location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`, }, }); } : undefined, }} configureSlot={{ enabled: true, 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, }), }} > {slotVisual} ) : ( setConfigureSlotModal({ amsId: ams.id, trayId: slotIdx, trayCount: ams.tray.length, }), }} > {slotVisual} )}
); })}
); })}
)} {/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */} {(htAms.length > 0 || (status.vt_tray && status.vt_tray.tray_type)) && (
{/* 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 // Global tray ID = ams.id * 4 + tray.id const globalTrayId = ams.id * 4 + (tray?.id ?? 0); 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]; // 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) || hexToBasicColorName(tray.tray_color), colorHex: tray.tray_color || null, kFactor: formatKValue(tray.k), fillLevel: hasFillLevel ? tray.remain : null, trayUuid: tray.tray_uuid || null, } : 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 = (
{tray?.tray_type || '—'}
{/* Fill bar */}
{hasFillLevel ? (
) : tray?.tray_type ? (
) : null}
); return (
{/* Row 1: Label + Nozzle */}
{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({ trayUuid: uuid, trayInfo: { type: filamentData.profile, color: filamentData.colorHex || '', location: getAmsLabel(ams.id, ams.tray.length), }, }); } : undefined, }} configureSlot={{ enabled: true, 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, }), }} > {slotVisual} ) : ( setConfigureSlotModal({ amsId: ams.id, trayId: htSlotId, trayCount: ams.tray.length, }), }} > {slotVisual} )}
{/* Stats stacked vertically: Temp on top, Humidity below */} {(ams.humidity != null || ams.temp != null) && (
{ams.temp != null && ( setAmsHistoryModal({ amsId: ams.id, amsLabel: getAmsLabel(ams.id, ams.tray.length), mode: 'temperature', })} compact /> )} {ams.humidity != null && ( setAmsHistoryModal({ amsId: ams.id, amsLabel: getAmsLabel(ams.id, ams.tray.length), mode: 'humidity', })} compact /> )}
)}
); })} {/* External spool - name top, slot below (no stats) */} {status.vt_tray && status.vt_tray.tray_type && (() => { const extTray = status.vt_tray; // Check if external spool is active (tray_now = 254) const isExtActive = effectiveTrayNow === 254; // Get cloud preset info if available const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null; // Get saved slot preset mapping (external spool uses amsId=255, trayId=0) const extSlotPreset = slotPresets?.[255 * 4 + 0]; // Build filament data for hover card 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) || hexToBasicColorName(extTray.tray_color), colorHex: extTray.tray_color || null, kFactor: formatKValue(extTray.k), fillLevel: null, // External spool has unknown fill level trayUuid: extTray.tray_uuid || null, }; const extSlotContent = (
{extTray.tray_type || 'Spool'}
{/* Unknown fill level - subtle bar */}
); return (
{/* Row 1: Label */}
External
{/* Row 2: Slot (full width since no stats) */} { setLinkSpoolModal({ trayUuid: uuid, trayInfo: { type: extFilamentData.profile, color: extFilamentData.colorHex || '', location: 'External Spool', }, }); } : undefined, }} configureSlot={{ enabled: true, onConfigure: () => setConfigureSlotModal({ amsId: 255, // External spool indicator trayId: 0, trayCount: 1, // External = single slot trayType: extTray.tray_type || undefined, trayColor: extTray.tray_color || undefined, traySubBrands: extTray.tray_sub_brands || undefined, trayInfoIdx: extTray.tray_info_idx || 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 */}
)} {/* Connection Info & Actions - hidden in compact mode */} {viewMode === 'expanded' && (

{printer.ip_address}

{printer.serial_number}

{/* Chamber Light Toggle */} {/* Camera Button */} {/* Split button: main part toggles detection, chevron opens modal */}
)} {/* File Manager Modal */} {showFileManager && ( setShowFileManager(false)} /> )} {/* MQTT Debug Modal */} {showMQTTDebug && ( setShowMQTTDebug(false)} /> )} {/* 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 ? ( <>

Calibration Required

Please ensure the build plate is completely empty, then click Calibrate.

Calibration captures a reference image of the empty plate. Future checks will compare against this reference to detect objects.

Tip: You can store up to 5 calibrations for different plates. The system automatically uses the best match when checking.

) : ( <>

{plateCheckResult.is_empty ? 'Plate appears empty' : 'Objects detected on plate'}

Confidence: {Math.round(plateCheckResult.confidence * 100)}% | Difference: {plateCheckResult.difference_percent.toFixed(1)}%

{plateCheckResult.debug_image_url && (

Analysis preview:

Plate detection analysis

Green box = detection area, Red overlay = differences from calibration

)}

{plateCheckResult.message}

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

Saved References ({plateReferences.references.length}/{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="Label..." /> ) : (

setEditingRefLabel({ index: ref.index, label: ref.label })} title={ref.label ? `${ref.label} - Click to edit` : 'Click to add label'} > {ref.label || No label}

)} {/* Timestamp */}

{ref.timestamp ? new Date(ref.timestamp).toLocaleDateString() : ''}

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

Detection Area (ROI)

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

Adjust the detection area to focus on the build plate. The green box in the preview shows the current area.

) : (

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 Popup */} {showSkipObjectsModal && (
setShowSkipObjectsModal(false)} onKeyDown={(e) => e.key === 'Escape' && setShowSkipObjectsModal(false)} tabIndex={-1} ref={(el) => el?.focus()} > {/* Backdrop */}
{/* Modal */}
e.stopPropagation()} > {/* Header */}
Skip Objects
{!objectsData ? (
) : objectsData.objects.length === 0 ? (

No objects found

Objects are loaded when a print starts

) : (
{/* Info Banner */}

Match IDs with your printer display

The printer screen shows object IDs on the build plate

{objectsData.skipped_count}/{objectsData.total} skipped
{/* Layer Warning */} {(status?.layer_num ?? 0) <= 1 && (

Wait for layer 2+ to skip objects (currently layer {status?.layer_num ?? 0})

)} {/* Content: Image + List side by side */}
{/* Left: Preview Image with object markers */}
{status?.cover_url ? ( Print preview ) : (
)} {/* Object ID markers overlay - positioned based on object data */} {objectsData.objects.length > 0 && (
{objectsData.objects.map((obj, idx) => { let x: number, y: number; // Use position data if available, otherwise fall back to grid if (obj.x != null && obj.y != null && objectsData.bbox_all) { // bbox_all defines the visible area in the top_N.png image // Format: [x_min, y_min, x_max, y_max] in mm const [xMin, yMin, xMax, yMax] = objectsData.bbox_all; const bboxWidth = xMax - xMin; const bboxHeight = yMax - yMin; // The image shows bbox_all area with some padding (~5-10%) const padding = 8; const contentArea = 100 - (padding * 2); // Map object position to image percentage x = padding + ((obj.x - xMin) / bboxWidth) * contentArea; // Y axis: image Y increases downward, but 3D Y increases toward back y = padding + ((yMax - obj.y) / bboxHeight) * contentArea; // Clamp to valid range x = Math.max(5, Math.min(95, x)); y = Math.max(5, Math.min(95, y)); } else if (obj.x != null && obj.y != null) { // Fallback: use full build plate (256mm) const buildPlate = 256; x = (obj.x / buildPlate) * 100; y = 100 - (obj.y / buildPlate) * 100; x = Math.max(5, Math.min(95, x)); y = Math.max(5, Math.min(95, y)); } else { // Fallback: arrange in a grid pattern over the build plate area const cols = Math.ceil(Math.sqrt(objectsData.objects.length)); const row = Math.floor(idx / cols); const col = idx % cols; const rows = Math.ceil(objectsData.objects.length / cols); x = 15 + (col * (70 / cols)) + (35 / cols); y = 15 + (row * (70 / rows)) + (35 / rows); } return (
{obj.id}
); })}
)} {/* Object count overlay */}
{objectsData.objects.filter(o => !o.skipped).length} active
{/* Right: Object List with prominent IDs */}
{objectsData.objects.map((obj) => (
{/* Large prominent ID badge */}
{obj.id} ID
{/* Object name and status */}
{obj.name} {obj.skipped && ( Will be skipped )}
{/* Skip button */} {!obj.skipped ? ( ) : ( Skipped )}
))}
)}
)} {/* HMS Error Modal */} {showHMSModal && ( setShowHMSModal(false)} /> )} {/* 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)} trayUuid={linkSpoolModal.trayUuid} trayInfo={linkSpoolModal.trayInfo} /> )} {/* Configure AMS Slot Modal */} {configureSlotModal && ( setConfigureSlotModal(null)} printerId={printer.id} slotInfo={configureSlotModal} 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 [form, setForm] = useState({ name: '', serial_number: '', ip_address: '', access_code: '', model: '', 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 [subnet, setSubnet] = useState('192.168.1.0/24'); const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 }); // Fetch discovery info on mount useEffect(() => { discoveryApi.getInfo().then(info => { setIsDocker(info.is_docker); }).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 : 'Failed to start discovery'); setDiscovering(false); setHasScanned(true); } }; // Map SSDP model codes to dropdown values const mapModelCode = (ssdpModel: string | null): string => { if (!ssdpModel) return ''; const modelMap: Record = { // H2 Series 'O1D': 'H2D', 'O1C': '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', 'H2C': 'H2C', 'H2S': 'H2S', }; return modelMap[ssdpModel] || ssdpModel; }; 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()}>

Add Printer

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

Docker detected. Enter your printer's subnet in CIDR notation. Requires network_mode: host in docker-compose.yml.

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

{printer.name || printer.serial}

{mapModelCode(printer.model) || 'Unknown'} • {printer.ip_address} {printer.serial.startsWith('unknown-') && ( • Serial required )}

))}
)} {discovering && (

{isDocker ? 'Scanning subnet for Bambu printers...' : 'Scanning network...'}

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

No printers found{isDocker ? ' in the specified subnet' : ' on the network'}.

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

All discovered printers are already configured.

)}
{ e.preventDefault(); onAdd(form); }} className="space-y-4" >
setForm({ ...form, name: e.target.value })} placeholder="My Printer" />
setForm({ ...form, ip_address: e.target.value })} placeholder="192.168.1.100" />
setForm({ ...form, serial_number: e.target.value })} placeholder="01P00A000000000" />
setForm({ ...form, access_code: e.target.value })} placeholder="From printer settings" />
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 queryClient = useQueryClient(); const { showToast } = useToast(); const [uploadStatus, setUploadStatus] = useState(null); const [isUploading, setIsUploading] = useState(false); const [pollInterval, setPollInterval] = useState(null); // Prepare check query const { data: prepareInfo, isLoading: isPreparing } = useQuery({ queryKey: ['firmwarePrepare', printer.id], queryFn: () => firmwareApi.prepareUpload(printer.id), staleTime: 30000, }); // 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('Firmware uploaded! Trigger update from printer screen.', 'success'); queryClient.invalidateQueries({ queryKey: ['firmwareUpdate', printer.id] }); } } } catch { // Ignore errors during polling } }, 2000); setPollInterval(interval); }, onError: (error: Error) => { showToast(`Failed to start upload: ${error.message}`, 'error'); setIsUploading(false); }, }); // Cleanup on unmount useEffect(() => { return () => { if (pollInterval) clearInterval(pollInterval); }; }, [pollInterval]); const handleStartUpload = () => { setUploadStatus(null); uploadMutation.mutate(); }; return (

Firmware Update

{printer.name}

{/* Version Info */}
Current: {firmwareInfo.current_version || 'Unknown'}
Latest: {firmwareInfo.latest_version}
{firmwareInfo.release_notes && (
Release Notes
{firmwareInfo.release_notes}
)}
{/* Status / Progress */} {isPreparing ? (
Checking prerequisites...
) : prepareInfo && !isUploading && !uploadStatus ? (
{prepareInfo.can_proceed ? (
SD card ready. Click below to upload firmware.
) : (
{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' && (

Firmware uploaded to SD card!

To apply the update on your printer:

  1. On the printer's touchscreen, go to Settings
  2. Navigate to Firmware
  3. Select Update from SD card
  4. The update will take 10-20 minutes
)} {/* Buttons */}
{prepareInfo?.can_proceed && !isUploading && uploadStatus?.status !== 'complete' && ( )}
); } function EditPrinterModal({ printer, onClose, }: { printer: Printer; onClose: () => void; }) { const queryClient = useQueryClient(); 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(); }, }); // 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()}>

Edit Printer

setForm({ ...form, name: e.target.value })} placeholder="My Printer" />
setForm({ ...form, ip_address: e.target.value })} placeholder="192.168.1.100" />

Serial number cannot be changed

setForm({ ...form, access_code: e.target.value })} placeholder="Leave empty to keep current" />
setForm({ ...form, location: e.target.value })} placeholder="e.g., Workshop, Office, Basement" />

Used to group printers on the dashboard

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 [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(); // 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; // 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); }, }); 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 (

Printers

{/* 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)} />
Offline printers with smart plugs
{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 ? (
Loading printers...
) : printers?.length === 0 ? (

No printers configured yet

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

{location} ({locationPrinters.length})

= 3 ? 'gap-6' : ''} ${getGridClasses()}`}> {locationPrinters.map((printer) => ( 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) => ( 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; })} /> ))}
); }