import { useState, useEffect, useMemo, useRef } 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, LayoutGrid, LayoutList, Layers, Video, Search, Loader2, Square, Pause, Play, X, Monitor, } 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 } from '../api/client'; import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } 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 { 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 { useToast } from '../contexts/ToastContext'; // Bambu Lab color code mapping (color suffix from tray_id_name -> color name) // tray_id_name format: "A00-Y2" where Y2 is the color code const BAMBU_COLOR_CODES: Record = { // Yellows 'Y0': 'Yellow', 'Y1': 'Savana Yellow', 'Y2': 'Sunflower Yellow', 'Y3': 'Lemon Yellow', // Oranges 'O0': 'Orange', 'O1': 'Mandarin Orange', 'O2': 'Coral Orange', // Reds 'R0': 'Red', 'R1': 'Scarlet Red', 'R2': 'Magenta', 'R3': 'Sakura Pink', 'R4': 'Raspberry Red', // Pinks 'P0': 'Pink', 'P1': 'Sakura Pink', // Purples 'V0': 'Purple', 'V1': 'Violet', 'V2': 'Lilac Purple', // Blues 'B0': 'Blue', 'B1': 'Sky Blue', 'B2': 'Navy Blue', 'B3': 'Ice Blue', 'B4': 'Cyan', // Greens 'G0': 'Green', 'G1': 'Grass Green', 'G2': 'Lime Green', 'G3': 'Mint Green', 'G4': 'Olive Green', 'G5': 'Jungle Green', 'G6': 'Bambu Green', // Browns 'N0': 'Brown', 'N1': 'Peanut Brown', 'N2': 'Coffee Brown', 'N3': 'Caramel Brown', // Grays 'A0': 'Gray', 'A1': 'Charcoal Gray', 'A2': 'Silver Gray', 'A3': 'Titan Gray', // Blacks 'K0': 'Black', 'K1': 'Black', // Whites 'W0': 'White', 'W1': 'Jade White', 'W2': 'Ivory White', // Special 'T0': 'Transparent', 'C0': 'Marble', 'X0': 'Bronze', 'X1': 'Gold', 'X2': 'Silver', }; // 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; // Extract color code after the dash (e.g., "A00-Y2" -> "Y2") const parts = trayIdName.split('-'); if (parts.length < 2) return null; const colorCode = parts[1]; return BAMBU_COLOR_CODES[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 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): 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); const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); // 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 }; } 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', amsThresholds, }: { printer: Printer; hideIfDisconnected?: boolean; maintenanceInfo?: PrinterMaintenanceInfo; viewMode?: ViewMode; amsThresholds?: { humidityGood: number; humidityFair: number; tempGood: number; tempFair: number; }; }) { 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 { data: status } = useQuery({ queryKey: ['printerStatus', printer.id], queryFn: () => api.getPrinterStatus(printer.id), refetchInterval: 30000, // Fallback polling, WebSocket handles real-time }); // 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 }); // 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'] }); }, }); 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'), }); // 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); }, }); // 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; } return ( {/* 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-bambu-green/20 text-bambu-green' : wifiSignal >= -60 ? 'bg-bambu-green/20 text-bambu-green' : wifiSignal >= -70 ? 'bg-amber-500/20 text-amber-600' : wifiSignal >= -80 ? 'bg-orange-500/20 text-orange-600' : 'bg-red-500/20 text-red-600' }`} 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 && ( )}
)}
{/* 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)} )} {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 && ( • {new Date(lastPrint.completed_at).toLocaleDateString([], { month: 'short', day: 'numeric' })} )}

) : (

Ready to print

)} )}
{/* Queue Widget - shows next scheduled print */} {status.state !== 'RUNNING' && ( )} )} {/* Temperatures + Print Controls */} {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; // 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; return (
{/* Temperature cards */}
{/* 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

) : (
/* Empty placeholder to maintain grid */ )}
{/* Print control buttons */}
{/* Stop button - visible when printing/paused */} {/* 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; // Build filament data for hover card const filamentData = tray?.tray_type ? { vendor: (tray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', profile: cloudInfo?.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, } : 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 ? ( {slotVisual} ) : ( {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; // Build filament data for hover card const filamentData = tray?.tray_type ? { vendor: (tray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', profile: cloudInfo?.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, } : 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 ? ( {slotVisual} ) : ( {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; // Build filament data for hover card const extFilamentData = { vendor: (extTray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', profile: extCloudInfo?.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 }; const extSlotContent = (
{extTray.tray_type || 'Spool'}
{/* Unknown fill level - subtle bar */}
); return (
{/* Row 1: Label */}
External
{/* Row 2: Slot (full width since no stats) */} {extSlotContent}
); })()}
)}
); })()} )} {/* Smart Plug Controls - hidden in compact mode */} {smartPlug && viewMode === 'expanded' && (
{/* Plug name and status */}
{smartPlug.name} {plugStatus && ( {plugStatus.state || '?'} )} {/* Power consumption display */} {plugStatus?.energy?.power != null && plugStatus.state === 'ON' && ( {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}

)} {/* File Manager Modal */} {showFileManager && ( setShowFileManager(false)} /> )} {/* MQTT Debug Modal */} {showMQTTDebug && ( setShowMQTTDebug(false)} /> )} {/* 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) => { // Build plate is typically 256x256mm for X1C const buildPlateSize = 256; let x: number, y: number; // Use position data if available, otherwise fall back to grid if (obj.x != null && obj.y != null) { // Convert mm position to percentage (0-100) // Clamp to valid range and add padding x = Math.max(10, Math.min(90, (obj.x / buildPlateSize) * 100)); y = Math.max(10, Math.min(90, (obj.y / buildPlateSize) * 100)); } 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} /> )} {/* Edit Printer Modal */} {showEditModal && ( setShowEditModal(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 (e) { // 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) => { setForm({ ...form, name: printer.name || '', serial_number: printer.serial, 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}

))}
)} {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 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'; }); const [viewMode, setViewMode] = useState(() => { return (localStorage.getItem('printerViewMode') as ViewMode) || 'expanded'; }); const queryClient = useQueryClient(); 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, }); // 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 }); // 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'] }); 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)); }; const toggleViewMode = () => { const newMode = viewMode === 'expanded' ? 'compact' : 'expanded'; setViewMode(newMode); localStorage.setItem('printerViewMode', newMode); }; // 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 */}
{/* View mode toggle */}
{/* 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})

{locationPrinters.map((printer) => ( ))}
))}
) : ( /* Regular grid view */
{sortedPrinters.map((printer) => ( ))}
)} {showAddModal && ( setShowAddModal(false)} onAdd={(data) => addMutation.mutate(data)} existingSerials={printers?.map(p => p.serial_number) || []} /> )}
); }