import { useState, useMemo, useEffect, useCallback } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react'; import { api } from '../api/client'; import type { KProfile } from '../api/client'; import { Button } from './Button'; interface SlotInfo { amsId: number; trayId: number; trayCount: number; trayType?: string; trayColor?: string; traySubBrands?: string; trayInfoIdx?: string; } // Get proper AMS label (handles HT AMS with ID 128+) function getAmsLabel(amsId: number, trayCount: number): string { // External spool if (amsId === 255) return 'External'; let normalizedId: number; let isHt = false; if (amsId >= 128 && amsId <= 135) { // HT AMS range: 128-135 → A-H normalizedId = amsId - 128; isHt = true; } else if (amsId >= 0 && amsId <= 3) { // Regular AMS range: 0-3 → A-D normalizedId = amsId; // Check tray count as secondary indicator isHt = trayCount === 1; } else { // Unknown range - fallback to A normalizedId = 0; } // Cap to valid letter range (A-H) normalizedId = Math.max(0, Math.min(normalizedId, 7)); const letter = String.fromCharCode(65 + normalizedId); return isHt ? `HT-${letter}` : `AMS-${letter}`; } // Convert setting_id to tray_info_idx (filament_id format) // Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05" function convertToTrayInfoIdx(settingId: string): string { // Strip version suffix if present (e.g., GFSL05_07 -> GFSL05) const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId; // Bambu presets start with "GFS" - remove the 'S' to get filament_id if (baseId.startsWith('GFS')) { return 'GF' + baseId.slice(3); } // User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix) // This follows the pattern that filament_id and setting_id share the same base ID if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) { return baseId; // Use base ID without version suffix } // For other formats, use as-is return baseId; } interface ConfigureAmsSlotModalProps { isOpen: boolean; onClose: () => void; printerId: number; slotInfo: SlotInfo; nozzleDiameter?: string; onSuccess?: () => void; } // Known filament material types const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET']; // Extract filament type from preset name by finding known material type function parsePresetName(name: string): { material: string; brand: string; variant: string } { // Remove printer/nozzle suffix first const withoutSuffix = name.replace(/@.+$/, '').trim(); // Try to find a known material type in the name const upperName = withoutSuffix.toUpperCase(); for (const mat of MATERIAL_TYPES) { // Use word boundary to match whole words only const regex = new RegExp(`\\b${mat}\\b`, 'i'); if (regex.test(upperName)) { // Found material, extract brand (everything before material) and variant (after) const parts = withoutSuffix.split(regex); const brand = parts[0]?.trim() || ''; const variant = parts[1]?.trim() || ''; return { material: mat, brand, variant }; } } // Fallback: assume first word is brand, second is material const parts = withoutSuffix.split(/\s+/); if (parts.length >= 2) { return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') }; } return { material: withoutSuffix, brand: '', variant: '' }; } // Check if a preset is a user preset (not built-in) function isUserPreset(settingId: string): boolean { // Built-in presets have specific patterns, user presets are UUIDs return !settingId.startsWith('GF') && !settingId.startsWith('P1'); } // Common color name to hex mapping const COLOR_NAME_MAP: Record = { // Basic colors 'white': 'FFFFFF', 'black': '000000', 'red': 'FF0000', 'green': '00FF00', 'blue': '0000FF', 'yellow': 'FFFF00', 'cyan': '00FFFF', 'magenta': 'FF00FF', 'orange': 'FFA500', 'purple': '800080', 'pink': 'FFC0CB', 'brown': '8B4513', 'gray': '808080', 'grey': '808080', // Filament-specific colors 'jade white': 'FFFEF2', 'ivory': 'FFFFF0', 'beige': 'F5F5DC', 'cream': 'FFFDD0', 'silver': 'C0C0C0', 'gold': 'FFD700', 'bronze': 'CD7F32', 'copper': 'B87333', 'navy': '000080', 'teal': '008080', 'olive': '808000', 'maroon': '800000', 'coral': 'FF7F50', 'salmon': 'FA8072', 'lime': '32CD32', 'mint': '98FF98', 'forest green': '228B22', 'sky blue': '87CEEB', 'royal blue': '4169E1', 'turquoise': '40E0D0', 'lavender': 'E6E6FA', 'violet': 'EE82EE', 'plum': 'DDA0DD', 'tan': 'D2B48C', 'chocolate': 'D2691E', 'charcoal': '36454F', 'slate': '708090', 'transparent': '000000', // Will need special handling 'natural': 'F5F5DC', 'wood': 'DEB887', }; // Quick-select color presets (common filament colors) // Basic colors shown by default const QUICK_COLORS_BASIC = [ { name: 'White', hex: 'FFFFFF' }, { name: 'Black', hex: '000000' }, { name: 'Red', hex: 'FF0000' }, { name: 'Blue', hex: '0000FF' }, { name: 'Green', hex: '00AA00' }, { name: 'Yellow', hex: 'FFFF00' }, { name: 'Orange', hex: 'FFA500' }, { name: 'Gray', hex: '808080' }, ]; // Extended colors shown when expanded const QUICK_COLORS_EXTENDED = [ { name: 'Cyan', hex: '00FFFF' }, { name: 'Magenta', hex: 'FF00FF' }, { name: 'Purple', hex: '800080' }, { name: 'Pink', hex: 'FFC0CB' }, { name: 'Brown', hex: '8B4513' }, { name: 'Beige', hex: 'F5F5DC' }, { name: 'Navy', hex: '000080' }, { name: 'Teal', hex: '008080' }, { name: 'Lime', hex: '32CD32' }, { name: 'Gold', hex: 'FFD700' }, { name: 'Silver', hex: 'C0C0C0' }, { name: 'Maroon', hex: '800000' }, { name: 'Olive', hex: '808000' }, { name: 'Coral', hex: 'FF7F50' }, { name: 'Salmon', hex: 'FA8072' }, { name: 'Turquoise', hex: '40E0D0' }, { name: 'Violet', hex: 'EE82EE' }, { name: 'Indigo', hex: '4B0082' }, { name: 'Chocolate', hex: 'D2691E' }, { name: 'Tan', hex: 'D2B48C' }, { name: 'Slate', hex: '708090' }, { name: 'Charcoal', hex: '36454F' }, { name: 'Ivory', hex: 'FFFFF0' }, { name: 'Cream', hex: 'FFFDD0' }, ]; // Try to convert color name to hex function colorNameToHex(name: string): string | null { const normalized = name.toLowerCase().trim(); return COLOR_NAME_MAP[normalized] || null; } export function ConfigureAmsSlotModal({ isOpen, onClose, printerId, slotInfo, nozzleDiameter = '0.4', onSuccess, }: ConfigureAmsSlotModalProps) { const [selectedPresetId, setSelectedPresetId] = useState(''); const [selectedKProfile, setSelectedKProfile] = useState(null); const [colorHex, setColorHex] = useState(''); // Just the 6-char hex, no alpha const [colorInput, setColorInput] = useState(''); // User's text input (name or hex) const [searchQuery, setSearchQuery] = useState(''); const [showSuccess, setShowSuccess] = useState(false); const [showExtendedColors, setShowExtendedColors] = useState(false); // Fetch cloud settings const { data: cloudSettings, isLoading: settingsLoading } = useQuery({ queryKey: ['cloudSettings'], queryFn: () => api.getCloudSettings(), enabled: isOpen, }); // Fetch K profiles const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({ queryKey: ['kprofiles', printerId, nozzleDiameter], queryFn: () => api.getKProfiles(printerId, nozzleDiameter), enabled: isOpen && !!printerId, }); // Configure slot mutation const configureMutation = useMutation({ mutationFn: async () => { if (!selectedPresetId) throw new Error('No filament preset selected'); // Get the selected preset details const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId); if (!selectedPreset) throw new Error('Selected preset not found'); // Parse the preset name for filament info const parsed = parsePresetName(selectedPreset.name); // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020) const caliIdx = selectedKProfile?.slot_id ?? -1; // Use custom color if set, otherwise use current slot color or default const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF'; // Create the tray_sub_brands from preset name (without printer/nozzle suffix) const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim(); // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId); // For user presets (not starting with GF), fetch the detail to get the real filament_id if (!selectedPresetId.startsWith('GFS')) { try { const detail = await api.getCloudSettingDetail(selectedPresetId); if (detail.filament_id) { trayInfoIdx = detail.filament_id; } else if (detail.base_id) { // If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it // This is common for user presets that inherit from Bambu presets trayInfoIdx = convertToTrayInfoIdx(detail.base_id); console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`); } } catch (e) { console.warn('Failed to fetch preset detail for filament_id:', e); // Fall back to derived tray_info_idx } } // Default temp range based on material type let tempMin = 190; let tempMax = 230; const material = parsed.material.toUpperCase(); if (material.includes('PLA')) { tempMin = 190; tempMax = 230; } else if (material.includes('PETG')) { tempMin = 220; tempMax = 260; } else if (material.includes('ABS')) { tempMin = 240; tempMax = 280; } else if (material.includes('ASA')) { tempMin = 240; tempMax = 280; } else if (material.includes('TPU')) { tempMin = 200; tempMax = 240; } else if (material.includes('PC')) { tempMin = 260; tempMax = 300; } else if (material.includes('PA') || material.includes('NYLON')) { tempMin = 250; tempMax = 290; } // Parse K value from selected profile const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0; // Configure the slot via MQTT const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, { tray_info_idx: trayInfoIdx, tray_type: parsed.material || 'PLA', tray_sub_brands: traySubBrands, tray_color: color + 'FF', // Add alpha nozzle_temp_min: tempMin, nozzle_temp_max: tempMax, cali_idx: caliIdx, nozzle_diameter: nozzleDiameter, setting_id: selectedPresetId, // Full setting ID for slicer compatibility // Pass K profile's filament_id and setting_id for proper linking kprofile_filament_id: selectedKProfile?.filament_id, kprofile_setting_id: selectedKProfile?.setting_id || undefined, // Also pass the K value directly for extrusion_cali_set command k_value: kValue, }); // Save the preset mapping so we can display the correct name in the UI // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx, // which can't be resolved to a name via the filamentInfo API try { await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands); } catch (e) { console.warn('Failed to save slot preset mapping:', e); // Don't fail the whole operation - slot was configured successfully } return result; }, onSuccess: () => { setShowSuccess(true); onSuccess?.(); // Close after showing success briefly setTimeout(() => { setShowSuccess(false); onClose(); }, 1500); }, }); // Reset slot mutation const resetMutation = useMutation({ mutationFn: async () => { return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId); }, onSuccess: () => { setShowSuccess(true); onSuccess?.(); setTimeout(() => { setShowSuccess(false); onClose(); }, 1500); }, }); // Filter filament presets based on search const filteredPresets = useMemo(() => { if (!cloudSettings?.filament) return []; const query = searchQuery.toLowerCase(); return cloudSettings.filament .filter(p => { if (!query) return true; return p.name.toLowerCase().includes(query); }) .sort((a, b) => { // Sort user presets first, then alphabetically const aIsUser = isUserPreset(a.setting_id); const bIsUser = isUserPreset(b.setting_id); if (aIsUser && !bIsUser) return -1; if (!aIsUser && bIsUser) return 1; return a.name.localeCompare(b.name); }); }, [cloudSettings?.filament, searchQuery]); // Get full preset name for K profile filtering (brand + material, without printer suffix) const selectedPresetInfo = useMemo(() => { if (!selectedPresetId || !cloudSettings?.filament) return null; const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId); if (!selectedPreset) return null; // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle") let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim(); // Strip leading "# " from custom preset names (user convention) if (nameWithoutSuffix.startsWith('# ')) { nameWithoutSuffix = nameWithoutSuffix.slice(2).trim(); } const parsed = parsePresetName(nameWithoutSuffix); return { fullName: nameWithoutSuffix, material: parsed.material, brand: parsed.brand, }; }, [selectedPresetId, cloudSettings?.filament]); // For backwards compatibility with the label const selectedMaterial = selectedPresetInfo?.fullName || ''; const matchingKProfiles = useMemo(() => { if (!kprofilesData?.profiles || !selectedPresetInfo) return []; const { fullName, material, brand } = selectedPresetInfo; const upperFullName = fullName.toUpperCase(); const upperMaterial = material.toUpperCase(); const upperBrand = brand.toUpperCase(); // Material must be at least 2 chars to avoid false positives if (!upperMaterial || upperMaterial.length < 2) return []; // Filter profiles - require brand match if brand is present in selected preset const filtered = kprofilesData.profiles.filter(p => { const profileName = p.name.toUpperCase(); // If the selected preset has a brand (e.g., "Azurefilm PLA Wood"), // only show profiles that match the brand if (upperBrand) { // Must contain the brand name if (!profileName.includes(upperBrand)) { return false; } // And must contain the material type if (!profileName.includes(upperMaterial)) { return false; } return true; } // No brand in selected preset - match on full name or material // Priority 1: Exact match with full name if (profileName.includes(upperFullName)) { return true; } // Priority 2: Material type match (only when no brand specified) if (profileName.includes(upperMaterial)) { return true; } // Check for common material aliases const aliases: Record = { 'NYLON': ['PA', 'PA-CF', 'PA6'], 'PA': ['NYLON'], }; const materialAliases = aliases[upperMaterial] || []; for (const alias of materialAliases) { if (profileName.includes(alias)) { return true; } } return false; }); // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates) // Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D const seen = new Map(); for (const profile of filtered) { const key = `${profile.name}|${profile.k_value}`; const existing = seen.get(key); if (!existing) { seen.set(key, profile); } else if (profile.extruder_id === 1 && existing.extruder_id === 0) { // Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile seen.set(key, profile); } } return Array.from(seen.values()); }, [kprofilesData?.profiles, selectedPresetInfo]); // Pre-select current profile when modal opens, reset when closes useEffect(() => { if (isOpen && cloudSettings?.filament) { // Try to pre-select current profile based on trayInfoIdx if (slotInfo.trayInfoIdx) { const currentPreset = cloudSettings.filament.find( p => p.setting_id === slotInfo.trayInfoIdx ); if (currentPreset) { setSelectedPresetId(currentPreset.setting_id); } } } else if (!isOpen) { // Reset when modal closes setSelectedPresetId(''); setSelectedKProfile(null); setColorHex(''); setColorInput(''); setSearchQuery(''); setShowSuccess(false); } }, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]); // Auto-select best matching K profile when preset changes useEffect(() => { if (matchingKProfiles.length > 0) { // Auto-select first matching profile setSelectedKProfile(matchingKProfiles[0]); } else { setSelectedKProfile(null); } }, [selectedPresetId, matchingKProfiles]); // Escape key handler const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }, [onClose]); useEffect(() => { if (isOpen) { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); } }, [isOpen, handleKeyDown]); if (!isOpen) return null; const isLoading = settingsLoading || kprofilesLoading; const canSave = selectedPresetId && !configureMutation.isPending; // Get display color (custom or slot default) const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF'; return (
{/* Backdrop */}
{/* Modal */}
{/* Header */}

Configure AMS Slot

{/* Content */}
{/* Success overlay */} {showSuccess && (

Slot Configured!

Settings sent to printer

)} {/* Slot info */}

Configuring slot:

{slotInfo.trayColor && ( )} {getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1} {slotInfo.traySubBrands && ( ({slotInfo.traySubBrands}) )}
{isLoading ? (
) : ( <> {/* Filament Profile Select */}
setSearchQuery(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2" />
{filteredPresets.length === 0 ? (

{cloudSettings?.filament?.length === 0 ? 'No cloud presets. Login to Bambu Cloud to sync.' : 'No matching presets found.'}

) : ( filteredPresets.map((preset) => ( )) )}
{/* K Profile Select */}
{matchingKProfiles.length > 0 ? (
) : selectedPresetId ? (

No matching K profiles found. Default K=0.020 will be used.

) : ( Select a filament profile first )} {selectedKProfile && (

K={selectedKProfile.k_value} from printer calibration

)}
{/* Optional: Custom color */}
{/* Quick color buttons */}
{QUICK_COLORS_BASIC.map((color) => (
{/* Extended colors (collapsible) */} {showExtendedColors && (
{QUICK_COLORS_EXTENDED.map((color) => (
)} {/* Color input: name or hex */}
{ const input = e.target.value; setColorInput(input); // Try to parse as color name first const nameHex = colorNameToHex(input); if (nameHex) { setColorHex(nameHex); } else { // Try to parse as hex code const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase(); if (cleaned.length === 6) { setColorHex(cleaned); } else if (cleaned.length === 3) { // Expand shorthand hex (e.g., F00 -> FF0000) setColorHex(cleaned.split('').map(c => c + c).join('')); } } }} className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm" /> {colorHex && ( )}
{colorHex && (

Hex: #{colorHex}

)}
)}
{/* Footer */}
{/* Reset button on the left */} {/* Cancel and Configure buttons on the right */}
{/* Error */} {(configureMutation.isError || resetMutation.isError) && (
{(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}
)}
); }