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, Terminal, Power, PowerOff, Zap, Wrench, ChevronDown, Pencil, ArrowUp, ArrowDown, LayoutGrid, LayoutList, Layers, Video, Search, Loader2, } from 'lucide-react'; 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'; // 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} ); } // AMS 4-tray device icon with fillable colored spool slots (Bambu Studio style) interface AMS4TrayIconProps { colors: (string | null)[]; // Array of 4 colors (hex) or null for empty className?: string; } function AMS4TrayIcon({ colors, className }: AMS4TrayIconProps) { // Spool positions: x start, centered at 12.5, 21.5, 30.5, 39.5 // Each spool slot is 6 units wide (from 9.5-15.5, 18.5-24.5, etc.) const spoolSlots = [ { x: 9.5, cx: 12.5 }, { x: 18.5, cx: 21.5 }, { x: 27.5, cx: 30.5 }, { x: 36.5, cx: 39.5 }, ]; return ( {/* Outer casing with window */} {/* Spool color fills - rectangles that fill the visible window area */} {spoolSlots.map((slot, i) => ( colors[i] ? ( ) : ( ) ))} {/* Bottom half overlay (spool holders - creates rounded bottom edges) */} {/* Top half overlay (spool tops - creates rounded top edges) */} ); } // AMS 1-tray device icon (AMS-HT) with fillable colored slot (Bambu Studio style) interface AMS1TrayIconProps { color: string | null; // Hex color or null for empty className?: string; } function AMS1TrayIcon({ color, className }: AMS1TrayIconProps) { return ( {/* Filament color fill */} {color ? ( ) : ( )} {/* Device outline - top housing */} {/* Bottom base */} {/* Inner tray outline */} ); } // 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; } function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick }: 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; } function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick }: 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}`; } 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 [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 [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 }); // 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; // 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'] }); }, }); 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 */}
{/* 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 */} {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; return (
{/* Nozzle temp - combined for dual nozzle */}
{status.temperatures.nozzle_2 !== undefined ? ( <>

Left / Right

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

) : ( <>

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

)}
); })()} {/* AMS Units with Device Icons, Humidity & Temperature */} {amsData && amsData.length > 0 && viewMode === 'expanded' && (
{amsData.map((ams) => { // For dual nozzle printers, determine which nozzle this AMS is connected to // Use actual ams.id for map lookup (map uses real IDs: 0-3 for AMS, 128+ for AMS-HT) const mappedExtruderId = amsExtruderMap[String(ams.id)]; // Fallback: normalize ID for conventional mapping (0=R, 1=L) const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id; const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId; // Fallback: AMS 0 → extruder 0 (R), AMS 1 → extruder 1 (L) // Use printer.nozzle_count as primary source (stable), fallback to nozzle_2 temp const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined; // extruder 0 = Right, extruder 1 = Left const isLeftNozzle = extruderId === 1; const isRightNozzle = extruderId === 0; // Get colors for the AMS icon (null for empty slots) const slotColors = ams.tray.map(tray => tray.tray_color ? `#${tray.tray_color}` : (tray.tray_type ? '#333' : null) ); const isHtAms = ams.tray.length === 1; return (
{/* Nozzle badge + AMS device icon */}
{isDualNozzle && (isLeftNozzle || isRightNozzle) && ( )} {isHtAms ? ( ) : ( )}
{/* Label and filament info */}
{getAmsLabel(ams.id, ams.tray.length)} {/* Filament types and fill levels */}
{ams.tray.map((tray, i) => (
{tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'} {tray.tray_type && tray.remain >= 0 ? `${tray.remain}%` : '—'}
{i < ams.tray.length - 1 && ( · · )}
))}
{/* Humidity/temp - responsive positioning */} {(ams.humidity != null || ams.temp != null) && (
{ams.humidity != null && ( setAmsHistoryModal({ amsId: ams.id, amsLabel: getAmsLabel(ams.id, ams.tray.length), mode: 'humidity', })} /> )} {ams.temp != null && ( setAmsHistoryModal({ amsId: ams.id, amsLabel: getAmsLabel(ams.id, ams.tray.length), mode: 'temperature', })} /> )}
)}
); })} {/* External spool indicator */} {status.vt_tray && status.vt_tray.tray_type && (
External

{status.vt_tray.tray_sub_brands || status.vt_tray.tray_type || 'Spool'}

)}
)} )} {/* 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)} /> )} {/* 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)} /> )} ); } 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) || []} /> )}
); }