feat: dual external spool support, AMS slot model filtering & pre-population

Backend:
- Add dual external spool support for H2D (vt_tray as list: Ext-L/Ext-R)
- Add cloud filament ID map endpoint (/cloud/filament-id-map)
- Fix RFID spool data erased by periodic AMS updates (skip tag matcher
  for RFID-tagged trays)
- Fix AMS slot config overwrites RFID spool state
- Fix K-profile selection corrupts existing profiles on X1C/P1S
- Resolve K-profiles filament name via cloud filament ID map
- Update print scheduler and usage tracker for dual external spools

Frontend:
- Add printer model filtering to ConfigureAmsSlotModal (cloud/local/builtin
  presets filtered by @BBL model suffix and compatible_printers)
- Add pre-population for configured slots (preset, color, K-profile)
- Add K-Profiles view with accurate filament name resolution
- Internationalize all ConfigureAmsSlotModal strings (en/de/fr/it/ja — 21 keys)
- Add 5 new ConfigureAmsSlotModal tests (model filtering, pre-selection,
  color pre-population, i18n)
- Update PrintersPage for dual external spool rendering

Docs:
- Update CHANGELOG, README, website features, and wiki AMS docs
This commit is contained in:
maziggy
2026-02-15 08:04:01 +01:00
parent 12e92a301d
commit a37dfaf7fb
40 changed files with 10007 additions and 9175 deletions
+180 -39
View File
@@ -14,6 +14,9 @@ interface SlotInfo {
trayColor?: string;
traySubBrands?: string;
trayInfoIdx?: string;
extruderId?: number;
caliIdx?: number | null;
savedPresetId?: string;
}
// Get proper AMS label (handles HT AMS with ID 128+)
@@ -72,6 +75,7 @@ interface ConfigureAmsSlotModalProps {
printerId: number;
slotInfo: SlotInfo;
nozzleDiameter?: string;
printerModel?: string;
onSuccess?: () => void;
}
@@ -209,12 +213,23 @@ function colorNameToHex(name: string): string | null {
return COLOR_NAME_MAP[normalized] || null;
}
// Extract printer model from preset name suffix "@BBL X1C 0.4 nozzle" → "X1C"
function extractPresetModel(name: string): string | null {
const atIdx = name.indexOf('@');
if (atIdx < 0) return null;
const suffix = name.slice(atIdx + 1).trim();
const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
if (bblMatch) return bblMatch[1].trim();
return null;
}
export function ConfigureAmsSlotModal({
isOpen,
onClose,
printerId,
slotInfo,
nozzleDiameter = '0.4',
printerModel,
onSuccess,
}: ConfigureAmsSlotModalProps) {
const { t } = useTranslation();
@@ -256,6 +271,14 @@ export function ConfigureAmsSlotModal({
enabled: isOpen && !!printerId,
});
// Fetch color catalog
const { data: colorCatalog } = useQuery({
queryKey: ['colorCatalog'],
queryFn: () => api.getColorCatalog(),
enabled: isOpen,
staleTime: Infinity,
});
// Configure slot mutation
const configureMutation = useMutation({
mutationFn: async () => {
@@ -435,22 +458,44 @@ export function ConfigureAmsSlotModal({
// Collect IDs already covered by cloud and local to avoid duplicates in fallback
const coveredIds = new Set<string>();
// Currently-configured preset should always be shown (bypass model filter)
const savedId = slotInfo.savedPresetId;
const trayIdx = slotInfo.trayInfoIdx;
// 1. Cloud presets
if (cloudSettings?.filament) {
for (const cp of cloudSettings.filament) {
coveredIds.add(cp.setting_id);
if (!query || cp.name.toLowerCase().includes(query)) {
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
// Keep preset if it matches the slot's saved mapping or current tray_info_idx
const isCurrentPreset = savedId === cp.setting_id
|| (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));
if (!isCurrentPreset && query && !cp.name.toLowerCase().includes(query)) continue;
// Filter by printer model if set (skip for current preset)
if (!isCurrentPreset && printerModel) {
const presetModel = extractPresetModel(cp.name);
if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue;
}
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
}
}
// 2. Local presets
if (localPresets?.filament) {
for (const lp of localPresets.filament) {
if (!query || lp.name.toLowerCase().includes(query)) {
items.push({ id: `local_${lp.id}`, name: lp.name, source: 'local', isUser: false });
const localId = `local_${lp.id}`;
const isSaved = savedId === localId;
if (!isSaved && query && !lp.name.toLowerCase().includes(query)) continue;
// Filter by compatible_printers if set (skip for saved preset)
if (!isSaved && printerModel && lp.compatible_printers) {
const compatModels = lp.compatible_printers.split(';').map(p => {
// Extract model from "BBL X1C" → "X1C"
const trimmed = p.trim();
const bblMatch = trimmed.match(/^BBL\s+(.+)/i);
return bblMatch ? bblMatch[1].trim().toUpperCase() : trimmed.toUpperCase();
}).filter(Boolean);
if (compatModels.length > 0 && !compatModels.includes(printerModel.toUpperCase())) continue;
}
items.push({ id: localId, name: lp.name, source: 'local', isUser: false });
}
}
@@ -478,7 +523,7 @@ export function ConfigureAmsSlotModal({
if (!a.isUser && b.isUser) return 1;
return a.name.localeCompare(b.name);
});
}, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery]);
}, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
// Get full preset name for K profile filtering (brand + material, without printer suffix)
const selectedPresetInfo = useMemo(() => {
@@ -518,6 +563,41 @@ export function ConfigureAmsSlotModal({
// For backwards compatibility with the label
const selectedMaterial = selectedPresetInfo?.fullName || '';
// Filter color catalog entries matching the selected preset's brand + material
const catalogColors = useMemo(() => {
if (!colorCatalog || !selectedPresetInfo) return [];
const { fullName, brand } = selectedPresetInfo;
// Try to find colors matching the full preset name (e.g., "PLA Metal")
// The catalog uses the variant as part of the material field (e.g., material="PLA Metal")
// Extract the full material+variant from the preset name
const materialVariant = fullName.replace(/^(Bambu\s*(Lab)?|eSUN|Polymaker|Overture|Sunlu|Hatchbox)\s*/i, '').trim();
return colorCatalog.filter(entry => {
const entryMaterial = (entry.material || '').toUpperCase();
const entryManufacturer = entry.manufacturer.toUpperCase();
// Match material: try full material+variant first, then just material type
const materialMatch = entryMaterial === materialVariant.toUpperCase()
|| entryMaterial.includes(materialVariant.toUpperCase())
|| materialVariant.toUpperCase().includes(entryMaterial);
if (!materialMatch) return false;
// If brand is present, also match manufacturer
if (brand) {
const upperBrand = brand.toUpperCase();
// Fuzzy match: "Bambu" matches "Bambu Lab", etc.
if (!entryManufacturer.includes(upperBrand) && !upperBrand.includes(entryManufacturer)) {
return false;
}
}
return true;
});
}, [colorCatalog, selectedPresetInfo]);
const matchingKProfiles = useMemo(() => {
if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
@@ -575,34 +655,52 @@ export function ConfigureAmsSlotModal({
});
// 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
// Prefer the profile matching the slot's extruder (e.g. ext-R uses extruder 0, ext-L uses extruder 1)
const seen = new Map<string, KProfile>();
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
} else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
// Replace with profile matching slot's extruder
seen.set(key, profile);
}
}
return Array.from(seen.values());
}, [kprofilesData?.profiles, selectedPresetInfo]);
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId]);
// 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(
if (isOpen) {
// Pre-populate from saved preset mapping (most reliable)
if (slotInfo.savedPresetId) {
setSelectedPresetId(slotInfo.savedPresetId);
} else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
// Fallback: try to match by tray_info_idx in cloud presets
// First try exact match on setting_id
let currentPreset = cloudSettings.filament.find(
p => p.setting_id === slotInfo.trayInfoIdx
);
// Then try matching by converting setting_id → filament_id format
if (!currentPreset) {
currentPreset = cloudSettings.filament.find(
p => convertToTrayInfoIdx(p.setting_id) === slotInfo.trayInfoIdx
);
}
if (currentPreset) {
setSelectedPresetId(currentPreset.setting_id);
}
}
} else if (!isOpen) {
// Pre-populate color from current slot
if (slotInfo.trayColor) {
const hex = slotInfo.trayColor.slice(0, 6);
if (hex && hex !== '000000') {
setColorHex(hex);
}
}
} else {
// Reset when modal closes
setSelectedPresetId('');
setSelectedKProfile(null);
@@ -611,17 +709,25 @@ export function ConfigureAmsSlotModal({
setSearchQuery('');
setShowSuccess(false);
}
}, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]);
}, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament]);
// Auto-select best matching K profile when preset changes
useEffect(() => {
if (matchingKProfiles.length > 0) {
// Auto-select first matching profile
// Prefer the currently-active K-profile (by cali_idx) if available
if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {
const active = matchingKProfiles.find(p => p.slot_id === slotInfo.caliIdx);
if (active) {
setSelectedKProfile(active);
return;
}
}
// Fallback: first matching profile
setSelectedKProfile(matchingKProfiles[0]);
} else {
setSelectedKProfile(null);
}
}, [selectedPresetId, matchingKProfiles]);
}, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx]);
// Escape key handler
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -659,7 +765,7 @@ export function ConfigureAmsSlotModal({
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Settings2 className="w-5 h-5 text-bambu-blue" />
<h2 className="text-lg font-semibold text-white">Configure AMS Slot</h2>
<h2 className="text-lg font-semibold text-white">{t('configureAmsSlot.title')}</h2>
</div>
<button
onClick={onClose}
@@ -676,7 +782,7 @@ export function ConfigureAmsSlotModal({
<div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
<div className="text-center space-y-3">
<CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
<p className="text-lg font-semibold text-white">Slot Configured!</p>
<p className="text-lg font-semibold text-white">{t('configureAmsSlot.slotConfigured')}</p>
<p className="text-sm text-bambu-gray">{t('configureAmsSlot.settingsSentToPrinter')}</p>
</div>
</div>
@@ -684,7 +790,7 @@ export function ConfigureAmsSlotModal({
{/* Slot info */}
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">Configuring slot:</p>
<p className="text-xs text-bambu-gray mb-1">{t('configureAmsSlot.configuringSlot')}</p>
<div className="flex items-center gap-2">
{slotInfo.trayColor && (
<span
@@ -693,7 +799,7 @@ export function ConfigureAmsSlotModal({
/>
)}
<span className="text-white font-medium">
{getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1}
{t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
</span>
{slotInfo.traySubBrands && (
<span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
@@ -710,7 +816,7 @@ export function ConfigureAmsSlotModal({
{/* Filament Profile Select */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
Filament Profile <span className="text-red-400">*</span>
{t('configureAmsSlot.filamentProfile')} <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
@@ -724,13 +830,16 @@ export function ConfigureAmsSlotModal({
{filteredPresets.length === 0 ? (
<p className="text-center py-4 text-bambu-gray">
{(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
? 'No presets available. Login to Bambu Cloud or import local profiles.'
: 'No matching presets found.'}
? t('configureAmsSlot.noPresetsAvailable')
: t('configureAmsSlot.noMatchingPresets')}
</p>
) : (
filteredPresets.map((preset) => (
<button
key={preset.id}
ref={selectedPresetId === preset.id ? (el) => {
el?.scrollIntoView({ block: 'nearest' });
} : undefined}
onClick={() => setSelectedPresetId(preset.id)}
className={`w-full p-2 rounded-lg border text-left transition-colors ${
selectedPresetId === preset.id
@@ -768,10 +877,10 @@ export function ConfigureAmsSlotModal({
{/* K Profile Select */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
K Profile (Pressure Advance)
{t('configureAmsSlot.kProfileLabel')}
{selectedMaterial && (
<span className="ml-2 text-xs text-bambu-blue">
Filtering for: {selectedMaterial}
{t('configureAmsSlot.filteringFor', { material: selectedMaterial })}
</span>
)}
</label>
@@ -785,7 +894,7 @@ export function ConfigureAmsSlotModal({
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
>
<option value="">No K profile (use default 0.020)</option>
<option value="">{t('configureAmsSlot.noKProfile')}</option>
{matchingKProfiles.map((profile) => (
<option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
{profile.name} (K={profile.k_value})
@@ -796,16 +905,16 @@ export function ConfigureAmsSlotModal({
</div>
) : selectedPresetId ? (
<p className="text-sm text-bambu-gray italic py-2">
No matching K profiles found. Default K=0.020 will be used.
{t('configureAmsSlot.noMatchingKProfiles')}
</p>
) : (
<span className="inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30">
Select a filament profile first
{t('configureAmsSlot.selectFilamentFirst')}
</span>
)}
{selectedKProfile && (
<p className="text-xs text-bambu-green mt-1">
K={selectedKProfile.k_value} from printer calibration
{t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}
</p>
)}
</div>
@@ -813,8 +922,40 @@ export function ConfigureAmsSlotModal({
{/* Optional: Custom color */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
Custom Color (optional)
{t('configureAmsSlot.customColorLabel')}
</label>
{/* Catalog colors matching selected preset */}
{catalogColors.length > 0 && (
<div className="mb-3">
<p className="text-xs text-bambu-gray mb-1.5">
{t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}
</p>
<div className="flex flex-wrap gap-1.5">
{catalogColors.map((entry) => (
<button
key={entry.id}
onClick={() => {
const hex = entry.hex_color.replace('#', '').toUpperCase();
setColorHex(hex);
setColorInput(entry.color_name);
}}
className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${
colorHex === entry.hex_color.replace('#', '').toUpperCase()
? 'border-bambu-green scale-105'
: 'border-white/20 hover:border-white/40'
}`}
title={entry.color_name}
>
<span
className="w-4 h-4 rounded-full border border-white/30 flex-shrink-0"
style={{ backgroundColor: entry.hex_color }}
/>
<span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
</button>
))}
</div>
</div>
)}
{/* Quick color buttons */}
<div className="flex flex-wrap gap-1.5 mb-2">
{QUICK_COLORS_BASIC.map((color) => (
@@ -836,7 +977,7 @@ export function ConfigureAmsSlotModal({
<button
onClick={() => setShowExtendedColors(!showExtendedColors)}
className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
title={showExtendedColors ? 'Show less colors' : 'Show more colors'}
title={showExtendedColors ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}
>
{showExtendedColors ? '−' : '+'}
</button>
@@ -902,13 +1043,13 @@ export function ConfigureAmsSlotModal({
className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
title={t('configureAmsSlot.clearCustomColor')}
>
Clear
{t('configureAmsSlot.clear')}
</button>
)}
</div>
{colorHex && (
<p className="text-xs text-bambu-gray mt-1.5">
Hex: #{colorHex}
{t('configureAmsSlot.hexLabel', { hex: colorHex })}
</p>
)}
</div>
@@ -928,19 +1069,19 @@ export function ConfigureAmsSlotModal({
{resetMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Resetting...
{t('configureAmsSlot.resetting')}
</>
) : (
<>
<RotateCcw className="w-4 h-4" />
Reset Slot
{t('configureAmsSlot.resetSlot')}
</>
)}
</Button>
{/* Cancel and Configure buttons on the right */}
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
{t('configureAmsSlot.cancel')}
</Button>
<Button
onClick={() => configureMutation.mutate()}
@@ -949,12 +1090,12 @@ export function ConfigureAmsSlotModal({
{configureMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Configuring...
{t('configureAmsSlot.configuring')}
</>
) : (
<>
<Settings2 className="w-4 h-4" />
Configure Slot
{t('configureAmsSlot.configureSlot')}
</>
)}
</Button>