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
@@ -38,6 +38,7 @@ const mockPrinterStatus = {
remaining_time: 0,
filename: null,
wifi_signal: -50,
vt_tray: [],
};
describe('AddPrinterModal Discovery', () => {
@@ -18,6 +18,11 @@ vi.mock('../../api/client', () => ({
saveSlotPreset: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
getLocalPresets: vi.fn(),
getBuiltinFilaments: vi.fn(),
searchColors: vi.fn(),
getColorCatalog: vi.fn(),
resetAmsSlot: vi.fn(),
},
}));
@@ -69,10 +74,17 @@ const defaultProps = {
describe('ConfigureAmsSlotModal', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock scrollIntoView which is not available in jsdom
Element.prototype.scrollIntoView = vi.fn();
(api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue(mockKProfiles);
(api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(api.getLocalPresets as ReturnType<typeof vi.fn>).mockResolvedValue({ filament: [] });
(api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.searchColors as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.getColorCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.resetAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true, message: 'ok' });
});
it('renders nothing visible when closed', () => {
@@ -204,4 +216,69 @@ describe('ConfigureAmsSlotModal', () => {
const configureButton = screen.getByRole('button', { name: /Configure Slot/i });
expect(configureButton).toBeInTheDocument();
});
it('filters presets by printer model', async () => {
// Render with printerModel="H2D"
render(<ConfigureAmsSlotModal {...defaultProps} printerModel="H2D" />);
// Wait for presets to load - the H2D preset should be visible
await waitFor(() => {
expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument();
});
// The X1C preset should NOT be visible (filtered out by model)
expect(screen.queryByText(/Bambu PLA Basic @BBL X1C/)).not.toBeInTheDocument();
});
it('shows current preset even when it does not match model filter', async () => {
// Render with printerModel="H2D" but savedPresetId pointing to the X1C preset
const slotInfo = {
...defaultProps.slotInfo,
savedPresetId: 'GFSL05_09', // X1C preset
};
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} printerModel="H2D" />);
await waitFor(() => {
// Both should be visible - H2D matches model, X1C is saved preset
// Use the full preset name to match the list item (not the "Filtering for" label)
expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();
expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument();
});
});
it('pre-selects saved preset when opening configured slot', async () => {
const slotInfo = {
...defaultProps.slotInfo,
savedPresetId: 'GFSL05_09',
};
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
await waitFor(() => {
// The saved preset should have the selected style (green border)
// Use the full preset name to avoid matching the "Filtering for" label
const presetButton = screen.getByText('Bambu PLA Basic @BBL X1C').closest('button');
expect(presetButton).toHaveClass('bg-bambu-green/20');
});
});
it('pre-populates color from trayColor', async () => {
const slotInfo = {
...defaultProps.slotInfo,
trayColor: 'FF0000FF', // Red with alpha
};
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
await waitFor(() => {
expect(screen.getByTitle('White')).toBeInTheDocument();
});
// The hex display should show the pre-populated color
expect(screen.getByText('Hex: #FF0000', { exact: false })).toBeInTheDocument();
});
it('uses translated text for modal elements', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Configure AMS Slot')).toBeInTheDocument();
expect(screen.getByText('Filament Profile')).toBeInTheDocument();
});
// Check footer buttons
expect(screen.getByRole('button', { name: /Configure Slot/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Reset Slot/i })).toBeInTheDocument();
});
});
@@ -67,7 +67,7 @@ describe('PrintModal', () => {
return HttpResponse.json({ filaments: [] });
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
}),
http.post('/api/v1/archives/:id/reprint', () => {
return HttpResponse.json({ success: true });
@@ -13,7 +13,7 @@ import {
import type { PrinterStatus } from '../../api/client';
// Helper to create a minimal printer status with AMS data
function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray: PrinterStatus['vt_tray'] = []): PrinterStatus {
return {
ams,
vt_tray,
@@ -89,7 +89,7 @@ describe('buildLoadedFilaments', () => {
it('extracts external spool with tray_info_idx', () => {
const status = createPrinterStatus(
[],
{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
[{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
);
const result = buildLoadedFilaments(status);
@@ -339,7 +339,7 @@ describe('computeAmsMapping', () => {
};
const status = createPrinterStatus(
[],
{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
[{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
);
const result = computeAmsMapping(reqs, status);
@@ -56,6 +56,7 @@ const mockPrinterStatus = {
remaining_time: 0,
filename: null,
wifi_signal: -50,
vt_tray: [],
};
describe('PrintersPage', () => {
+5 -1
View File
@@ -204,7 +204,7 @@ export interface PrinterStatus {
hms_errors: HMSError[];
ams: AMSUnit[];
ams_exists: boolean;
vt_tray: AMSTray | null; // Virtual tray / external spool
vt_tray: AMSTray[]; // Virtual tray / external spool(s)
sdcard: boolean; // SD card inserted
store_to_sdcard: boolean; // Store sent files on SD card
timelapse: boolean; // Timelapse recording active
@@ -3028,6 +3028,8 @@ export const api = {
request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
getBuiltinFilaments: () =>
request<BuiltinFilament[]>('/cloud/builtin-filaments'),
getFilamentIdMap: () =>
request<Record<string, string>>('/cloud/filament-id-map'),
getCloudSettingDetail: (settingId: string) =>
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
createCloudSetting: (data: SlicerSettingCreate) =>
@@ -3425,6 +3427,8 @@ export const api = {
request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),
lookupColor: (manufacturer: string, colorName: string, material?: string) =>
request<ColorLookupResult>(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`),
searchColors: (manufacturer?: string, material?: string) =>
request<ColorCatalogEntry[]>(`/inventory/colors/search?${manufacturer ? `manufacturer=${encodeURIComponent(manufacturer)}` : ''}${manufacturer && material ? '&' : ''}${material ? `material=${encodeURIComponent(material)}` : ''}`),
linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) =>
request<InventorySpool>(`/inventory/spools/${spoolId}/link-tag`, {
method: 'PATCH',
+5 -3
View File
@@ -62,15 +62,17 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
if (!isOpen) return null;
// Filter out Bambu Lab spools (identified by RFID tag_uid or tray_uuid)
// and spools already assigned to other slots
// Filter out spools already assigned to other slots
const assignedSpoolIds = new Set(
(assignments || [])
.filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
.map(a => a.spool_id)
);
// External slots (amsId 254 or 255) have no RFID reader, so show all spools.
// AMS slots only show manual spools (no tag_uid or tray_uuid).
const isExternalSlot = amsId === 254 || amsId === 255;
const manualSpools = spools?.filter((spool: InventorySpool) =>
!spool.tag_uid && !spool.tray_uuid && !assignedSpoolIds.has(spool.id)
!assignedSpoolIds.has(spool.id) && (isExternalSlot || (!spool.tag_uid && !spool.tray_uuid))
);
const filteredSpools = manualSpools?.filter((spool: InventorySpool) => {
+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>
+66 -5
View File
@@ -150,6 +150,7 @@ interface KProfileModalProps {
printerId: number;
nozzleDiameter: string;
existingProfiles?: KProfile[]; // Existing profiles for filament selection
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
initialNote?: string; // Initial note value for the profile
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
@@ -164,6 +165,7 @@ function KProfileModal({
printerId,
nozzleDiameter,
existingProfiles = [],
builtinFilaments = [],
isDualNozzle = false,
initialNote = '',
initialNoteKey = null,
@@ -197,12 +199,21 @@ function KProfileModal({
const [note, setNote] = useState(initialNote);
// Extract unique filaments from existing K-profiles on the printer
// These have valid filament_ids that the printer recognizes
// Use builtin filament table for accurate name resolution (filament_id → name)
// Falls back to extracting from profile name for custom/unknown presets
const knownFilaments = React.useMemo(() => {
// Build lookup map from builtin filament names (includes cloud presets from parent)
const builtinMap = new Map<string, string>();
for (const bf of builtinFilaments) {
builtinMap.set(bf.filament_id, bf.name);
}
const filamentMap = new Map<string, { id: string; name: string }>();
for (const p of existingProfiles) {
if (p.filament_id && !filamentMap.has(p.filament_id)) {
const filamentName = extractFilamentName(p.name || '');
// Prefer builtin name (accurate), fall back to extracting from profile name
const builtinName = builtinMap.get(p.filament_id);
const filamentName = builtinName || extractFilamentName(p.name || '');
filamentMap.set(p.filament_id, {
id: p.filament_id,
name: filamentName || p.filament_id,
@@ -212,7 +223,7 @@ function KProfileModal({
return Array.from(filamentMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [existingProfiles]);
}, [existingProfiles, builtinFilaments]);
const saveMutation = useMutation({
mutationFn: (data: KProfileCreate) => {
@@ -769,6 +780,20 @@ export function KProfilesView() {
staleTime: 60000, // Cache for 1 minute
});
// Fetch builtin filament names for accurate filament_id → name resolution
const { data: builtinFilaments } = useQuery({
queryKey: ['builtinFilaments'],
queryFn: () => api.getBuiltinFilaments(),
staleTime: 300000, // Cache for 5 minutes (static data)
});
// Fetch filament_id → name mapping for user cloud presets (P* IDs)
const { data: filamentIdMap } = useQuery({
queryKey: ['filamentIdMap'],
queryFn: () => api.getFilamentIdMap(),
staleTime: 300000, // Cache for 5 minutes
});
// Fetch K-profile notes (stored locally)
const {
data: notesData,
@@ -807,6 +832,39 @@ export function KProfilesView() {
// Get connected printers for display
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
// Build filament lookup for name resolution (builtin + user cloud presets)
const builtinFilamentMap = React.useMemo(() => {
const map = new Map<string, string>();
if (builtinFilaments) {
for (const bf of builtinFilaments) {
map.set(bf.filament_id, bf.name);
}
}
// Also add user cloud presets (P* filament_ids resolved from cloud details)
if (filamentIdMap) {
for (const [fid, name] of Object.entries(filamentIdMap)) {
if (!map.has(fid)) {
map.set(fid, name);
}
}
}
return map;
}, [builtinFilaments, filamentIdMap]);
// Enriched builtin filaments array (builtin + cloud presets merged)
// Pass this to modals so they have the full filament name lookup
const enrichedBuiltinFilaments = React.useMemo(() => {
return Array.from(builtinFilamentMap.entries()).map(([fid, name]) => ({
filament_id: fid,
name,
}));
}, [builtinFilamentMap]);
// Resolve filament name: builtin table first, then extract from profile name
const resolveFilamentName = React.useCallback((profile: KProfile) => {
return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
}, [builtinFilamentMap]);
// Filter and sort profiles
// Note: nozzle diameter filtering is done server-side via MQTT request
const filteredProfiles = React.useMemo(() => {
@@ -841,13 +899,13 @@ export function KProfilesView() {
case 'k_value':
return parseFloat(a.k_value) - parseFloat(b.k_value);
case 'filament':
return extractFilamentName(a.name).localeCompare(extractFilamentName(b.name));
return resolveFilamentName(a).localeCompare(resolveFilamentName(b));
case 'name':
default:
return a.name.localeCompare(b.name);
}
});
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption]);
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption, resolveFilamentName]);
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
@@ -1394,6 +1452,7 @@ export function KProfilesView() {
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
builtinFilaments={enrichedBuiltinFilaments}
isDualNozzle={isDualNozzle}
initialNote={note}
initialNoteKey={key}
@@ -1418,6 +1477,7 @@ export function KProfilesView() {
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
builtinFilaments={enrichedBuiltinFilaments}
isDualNozzle={isDualNozzle}
onSaveNote={handleSaveNote}
hasPermission={hasPermission}
@@ -1438,6 +1498,7 @@ export function KProfilesView() {
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
builtinFilaments={enrichedBuiltinFilaments}
isDualNozzle={isDualNozzle}
onSaveNote={handleSaveNote}
hasPermission={hasPermission}
+20 -16
View File
@@ -41,22 +41,26 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
});
});
// Add external spool if loaded
if (printerStatus?.vt_tray?.tray_type) {
const color = normalizeColor(printerStatus.vt_tray.tray_color);
filaments.push({
type: printerStatus.vt_tray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: 0,
isHt: false,
isExternal: true,
label: 'External',
globalTrayId: 254,
trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
extruderId: hasDualNozzle ? 0 : undefined,
});
// Add external spool(s) if loaded
for (const extTray of printerStatus?.vt_tray ?? []) {
if (extTray.tray_type) {
const color = normalizeColor(extTray.tray_color);
const trayId = extTray.id ?? 254;
const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
filaments.push({
type: extTray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: trayId - 254,
isHt: false,
isExternal: true,
label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
globalTrayId: trayId,
trayInfoIdx: extTray.tray_info_idx || '',
extruderId: hasDualNozzle ? (trayId - 254) : undefined,
});
}
}
return filaments;
+24
View File
@@ -171,6 +171,8 @@ export default {
// Printer card
readyToPrint: 'Druckbereit',
external: 'Extern',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Druckarchive löschen',
noLabel: 'Keine Bezeichnung',
printPreview: 'Druckvorschau',
@@ -3204,15 +3206,37 @@ export default {
// Configure AMS Slot Modal
configureAmsSlot: {
title: 'AMS-Slot konfigurieren',
slotConfigured: 'Slot konfiguriert!',
configuringSlot: 'Slot wird konfiguriert:',
slotLabel: '{{ams}} Slot {{slot}}',
searchPresets: 'Voreinstellungen suchen...',
colorPlaceholder: 'Farbname oder Hex (z.B. braun, FF8800)',
clearCustomColor: 'Benutzerdefinierte Farbe löschen',
noCloudPresets: 'Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.',
noPresetsAvailable: 'Keine Voreinstellungen verfügbar. Melden Sie sich bei Bambu Cloud an oder importieren Sie lokale Profile.',
noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.',
custom: 'Benutzerdefiniert',
builtin: 'Integriert',
settingsSentToPrinter: 'Einstellungen an Drucker gesendet',
filamentProfile: 'Filamentprofil',
kProfileLabel: 'K-Profil (Pressure Advance)',
filteringFor: 'Filtern nach: {{material}}',
noKProfile: 'Kein K-Profil (Standard 0.020 verwenden)',
noMatchingKProfiles: 'Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.',
selectFilamentFirst: 'Zuerst ein Filamentprofil auswählen',
kFromCalibration: 'K={{value}} aus Druckerkalibrierung',
customColorLabel: 'Benutzerdefinierte Farbe (optional)',
presetColors: '{{name}} Farben:',
showLessColors: 'Weniger Farben anzeigen',
showMoreColors: 'Mehr Farben anzeigen',
clear: 'Löschen',
hexLabel: 'Hex: #{{hex}}',
resetting: 'Wird zurückgesetzt...',
resetSlot: 'Slot zurücksetzen',
cancel: 'Abbrechen',
configuring: 'Wird konfiguriert...',
configureSlot: 'Slot konfigurieren',
},
// GitHub Backup Settings
+24
View File
@@ -171,6 +171,8 @@ export default {
// Printer card
readyToPrint: 'Ready to print',
external: 'External',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Delete print archives',
noLabel: 'No label',
printPreview: 'Print preview',
@@ -3209,15 +3211,37 @@ export default {
// Configure AMS Slot Modal
configureAmsSlot: {
title: 'Configure AMS Slot',
slotConfigured: 'Slot Configured!',
configuringSlot: 'Configuring slot:',
slotLabel: '{{ams}} Slot {{slot}}',
searchPresets: 'Search presets...',
colorPlaceholder: 'Color name or hex (e.g., brown, FF8800)',
clearCustomColor: 'Clear custom color',
noCloudPresets: 'No cloud presets. Login to Bambu Cloud to sync.',
noPresetsAvailable: 'No presets available. Login to Bambu Cloud or import local profiles.',
noMatchingPresets: 'No matching presets found.',
custom: 'Custom',
builtin: 'Built-in',
settingsSentToPrinter: 'Settings sent to printer',
filamentProfile: 'Filament Profile',
kProfileLabel: 'K Profile (Pressure Advance)',
filteringFor: 'Filtering for: {{material}}',
noKProfile: 'No K profile (use default 0.020)',
noMatchingKProfiles: 'No matching K profiles found. Default K=0.020 will be used.',
selectFilamentFirst: 'Select a filament profile first',
kFromCalibration: 'K={{value}} from printer calibration',
customColorLabel: 'Custom Color (optional)',
presetColors: '{{name}} colors:',
showLessColors: 'Show less colors',
showMoreColors: 'Show more colors',
clear: 'Clear',
hexLabel: 'Hex: #{{hex}}',
resetting: 'Resetting...',
resetSlot: 'Reset Slot',
cancel: 'Cancel',
configuring: 'Configuring...',
configureSlot: 'Configure Slot',
},
// GitHub Backup Settings
+24
View File
@@ -171,6 +171,8 @@ export default {
// Printer card
readyToPrint: 'Prête à imprimer',
external: 'Externe',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Supprimer les archives d\'impression',
noLabel: 'Pas d\'étiquette',
printPreview: 'Aperçu avant impression',
@@ -3205,15 +3207,37 @@ export default {
// Configure AMS Slot Modal
configureAmsSlot: {
title: 'Configurer le slot AMS',
slotConfigured: 'Slot configuré !',
configuringSlot: 'Configuration du slot :',
slotLabel: '{{ams}} Slot {{slot}}',
searchPresets: 'Chercher presets...',
colorPlaceholder: 'Nom couleur ou hex (ex: brown, FF8800)',
clearCustomColor: 'Effacer couleur perso',
noCloudPresets: 'Profils Cloud absents. Connectez-vous.',
noPresetsAvailable: 'Aucun preset disponible. Connectez-vous à Bambu Cloud ou importez des profils locaux.',
noMatchingPresets: 'Aucun profil trouvé.',
custom: 'Perso',
builtin: 'Inclus',
settingsSentToPrinter: 'Réglages envoyés',
filamentProfile: 'Profil Filament',
kProfileLabel: 'Profil K (Pressure Advance)',
filteringFor: 'Filtrage pour : {{material}}',
noKProfile: 'Pas de profil K (utiliser défaut 0.020)',
noMatchingKProfiles: 'Aucun profil K trouvé. K=0.020 par défaut sera utilisé.',
selectFilamentFirst: 'Sélectionnez d\'abord un profil filament',
kFromCalibration: 'K={{value}} de la calibration imprimante',
customColorLabel: 'Couleur personnalisée (optionnel)',
presetColors: 'Couleurs {{name}} :',
showLessColors: 'Moins de couleurs',
showMoreColors: 'Plus de couleurs',
clear: 'Effacer',
hexLabel: 'Hex : #{{hex}}',
resetting: 'Réinitialisation...',
resetSlot: 'Réinitialiser le slot',
cancel: 'Annuler',
configuring: 'Configuration...',
configureSlot: 'Configurer le slot',
},
// GitHub Backup Settings
+37
View File
@@ -168,6 +168,8 @@ export default {
// Printer card
readyToPrint: 'Pronta a stampare',
external: 'Esterna',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: 'Elimina archivi stampa',
noLabel: 'Nessuna etichetta',
printPreview: 'Anteprima stampa',
@@ -2732,4 +2734,39 @@ export default {
replaceCarbonFilter: 'Sostituisci filtro a carbone attivo',
lubricateLeftNozzleRail: 'Lubrifica guida ugello sinistro (serie H2)',
},
// Configure AMS Slot Modal
configureAmsSlot: {
title: 'Configura Slot AMS',
slotConfigured: 'Slot configurato!',
configuringSlot: 'Configurazione slot:',
slotLabel: '{{ams}} Slot {{slot}}',
searchPresets: 'Cerca preset...',
colorPlaceholder: 'Nome colore o hex (es. marrone, FF8800)',
clearCustomColor: 'Cancella colore personalizzato',
noCloudPresets: 'Nessun preset cloud. Accedi a Bambu Cloud per sincronizzare.',
noPresetsAvailable: 'Nessun preset disponibile. Accedi a Bambu Cloud o importa profili locali.',
noMatchingPresets: 'Nessun preset corrispondente trovato.',
custom: 'Personalizzato',
builtin: 'Integrato',
settingsSentToPrinter: 'Impostazioni inviate alla stampante',
filamentProfile: 'Profilo filamento',
kProfileLabel: 'Profilo K (Pressure Advance)',
filteringFor: 'Filtrando per: {{material}}',
noKProfile: 'Nessun profilo K (usa predefinito 0.020)',
noMatchingKProfiles: 'Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.',
selectFilamentFirst: 'Seleziona prima un profilo filamento',
kFromCalibration: 'K={{value}} dalla calibrazione stampante',
customColorLabel: 'Colore personalizzato (opzionale)',
presetColors: 'Colori {{name}}:',
showLessColors: 'Mostra meno colori',
showMoreColors: 'Mostra più colori',
clear: 'Cancella',
hexLabel: 'Hex: #{{hex}}',
resetting: 'Ripristino...',
resetSlot: 'Ripristina slot',
cancel: 'Annulla',
configuring: 'Configurazione...',
configureSlot: 'Configura slot',
},
};
+24
View File
@@ -173,6 +173,8 @@ export default {
noPrintersConfigured: 'プリンターが設定されていません',
readyToPrint: '印刷可能',
external: '外部',
extL: 'Ext-L',
extR: 'Ext-R',
deleteArchives: '印刷アーカイブを削除',
willBeSkipped: 'スキップされます',
name: '名前',
@@ -2920,15 +2922,37 @@ export default {
// Configure AMS Slot Modal
configureAmsSlot: {
title: 'AMSスロットの設定',
slotConfigured: 'スロットを設定しました!',
configuringSlot: 'スロットを設定中:',
slotLabel: '{{ams}} スロット {{slot}}',
searchPresets: 'プリセットを検索...',
colorPlaceholder: '色名またはHex(例: 茶色、FF8800)',
clearCustomColor: 'カスタム色をクリア',
noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。',
noPresetsAvailable: 'プリセットがありません。Bambu Cloudにログインするか、ローカルプロファイルをインポートしてください。',
noMatchingPresets: '一致するプリセットが見つかりません。',
custom: 'カスタム',
builtin: '内蔵',
settingsSentToPrinter: '設定をプリンターに送信しました',
filamentProfile: 'フィラメントプロファイル',
kProfileLabel: 'Kプロファイル(Pressure Advance)',
filteringFor: 'フィルター中: {{material}}',
noKProfile: 'Kプロファイルなし(デフォルト0.020を使用)',
noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。',
selectFilamentFirst: 'まずフィラメントプロファイルを選択してください',
kFromCalibration: 'K={{value}}(プリンターキャリブレーションから)',
customColorLabel: 'カスタム色(オプション)',
presetColors: '{{name}}の色:',
showLessColors: '色を減らす',
showMoreColors: '色をもっと表示',
clear: 'クリア',
hexLabel: 'Hex: #{{hex}}',
resetting: 'リセット中...',
resetSlot: 'スロットをリセット',
cancel: 'キャンセル',
configuring: '設定中...',
configureSlot: 'スロットを設定',
},
// Email Settings
+218 -178
View File
@@ -1384,6 +1384,44 @@ function getStatusDisplay(state: string | null | undefined, stg_cur_name: string
}
}
// Map SSDP model codes to display names
function mapModelCode(ssdpModel: string | null): string {
if (!ssdpModel) return '';
const modelMap: Record<string, string> = {
// H2 Series
'O1D': 'H2D',
'O1E': 'H2D Pro',
'O2D': 'H2D Pro',
'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',
'H2D Pro': 'H2D Pro',
'H2C': 'H2C',
'H2S': 'H2S',
};
return modelMap[ssdpModel] || ssdpModel;
}
function PrinterCard({
printer,
hideIfDisconnected,
@@ -1469,6 +1507,9 @@ function PrinterCard({
trayColor?: string;
traySubBrands?: string;
trayInfoIdx?: string;
extruderId?: number;
caliIdx?: number | null;
savedPresetId?: string;
} | null>(null);
const [showFirmwareModal, setShowFirmwareModal] = useState(false);
const [plateCheckResult, setPlateCheckResult] = useState<{
@@ -1516,8 +1557,8 @@ function PrinterCard({
}
}
}
if (status?.vt_tray?.tray_info_idx) {
ids.add(status.vt_tray.tray_info_idx);
for (const vt of status?.vt_tray ?? []) {
if (vt.tray_info_idx) ids.add(vt.tray_info_idx);
}
if (status?.nozzle_rack) {
for (const slot of status.nozzle_rack) {
@@ -1582,18 +1623,20 @@ function PrinterCard({
}, [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<number>(255);
// Cache tray_now to prevent flickering when undefined values come in
// Valid tray IDs: 0-253 for AMS, 254 for external spool
// tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
const cachedTrayNow = useRef<number | undefined>(undefined);
const currentTrayNow = status?.tray_now;
// Update cache synchronously during render if we have a valid value
// Update cache: 255 means "no tray" so clear cache; valid values get cached
if (currentTrayNow !== undefined && currentTrayNow !== 255) {
cachedTrayNow.current = currentTrayNow;
} else if (currentTrayNow === 255) {
cachedTrayNow.current = undefined;
}
// Use cached value if current is 255/undefined but we had a valid value before
const effectiveTrayNow = (currentTrayNow === undefined || currentTrayNow === 255)
? cachedTrayNow.current
: currentTrayNow;
const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255)
? currentTrayNow
: cachedTrayNow.current;
// Fetch smart plug for this printer
const { data: smartPlug } = useQuery({
@@ -2658,7 +2701,7 @@ function PrinterCard({
})()}
{/* AMS Units - 2-Column Grid Layout */}
{amsData && amsData.length > 0 && viewMode === 'expanded' && (() => {
{(amsData?.length > 0 || status.vt_tray.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);
@@ -2915,6 +2958,9 @@ function PrinterCard({
trayColor: tray?.tray_color || undefined,
traySubBrands: tray?.tray_sub_brands || undefined,
trayInfoIdx: tray?.tray_info_idx || undefined,
extruderId: mappedExtruderId,
caliIdx: tray?.cali_idx,
savedPresetId: slotPreset?.preset_id,
}),
}}
>
@@ -2928,6 +2974,7 @@ function PrinterCard({
amsId: ams.id,
trayId: slotIdx,
trayCount: ams.tray.length,
extruderId: mappedExtruderId,
}),
}}
>
@@ -2945,7 +2992,7 @@ function PrinterCard({
)}
{/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */}
{(htAms.length > 0 || (status.vt_tray && status.vt_tray.tray_type)) && (
{(htAms.length > 0 || status.vt_tray.length > 0) && (
<div className="grid grid-cols-4 gap-3">
{/* HT AMS units - name/badge top, slot left, stats right */}
{htAms.map((ams) => {
@@ -3149,6 +3196,9 @@ function PrinterCard({
trayColor: tray?.tray_color || undefined,
traySubBrands: tray?.tray_sub_brands || undefined,
trayInfoIdx: tray?.tray_info_idx || undefined,
extruderId: mappedExtruderId,
caliIdx: tray?.cali_idx,
savedPresetId: slotPreset?.preset_id,
}),
}}
>
@@ -3162,6 +3212,7 @@ function PrinterCard({
amsId: ams.id,
trayId: htSlotId,
trayCount: ams.tray.length,
extruderId: mappedExtruderId,
}),
}}
>
@@ -3204,138 +3255,162 @@ function PrinterCard({
</div>
);
})}
{/* External spool - name top, slot below (no stats) */}
{status.vt_tray && status.vt_tray.tray_type && (() => {
const extTray = status.vt_tray;
// Check if external spool is active (tray_now = 254)
const isExtActive = effectiveTrayNow === 254;
// Get cloud preset info if available
const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
// Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
const extSlotPreset = slotPresets?.[255 * 4 + 0];
// Fill level fallback chain: Spoolman → Inventory spool
const extTrayTag = extTray.tray_uuid?.toUpperCase();
const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
const extInventoryAssignment = onGetAssignment?.(printer.id, 255, 0);
const extInventoryFill = (() => {
const sp = extInventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
})();
const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
// Build filament data for hover card
const extFilamentData = {
vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
colorHex: extTray.tray_color || null,
kFactor: formatKValue(extTray.k),
fillLevel: extEffectiveFill,
trayUuid: extTray.tray_uuid || null,
tagUid: extTray.tag_uid || null,
fillSource: extSpoolmanFill !== null ? 'spoolman' as const
: extInventoryFill !== null ? 'inventory' as const
: undefined,
};
const extSlotContent = (
<div className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
style={{
backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : '#333',
borderColor: isExtActive ? 'var(--accent)' : 'rgba(255,255,255,0.1)',
}}
/>
<div className="text-[9px] text-white font-bold truncate">
{extTray.tray_type || 'Spool'}
</div>
{/* Fill bar - use Spoolman data if available */}
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
{extSpoolmanFill !== null ? (
<div
className="h-full rounded-full transition-all"
style={{
width: `${extSpoolmanFill}%`,
backgroundColor: getFillBarColor(extSpoolmanFill),
}}
/>
) : (
<div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
)}
</div>
{/* External spool(s) - grouped in one card like regular AMS */}
{status.vt_tray.length > 0 && (
<div className={`p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30 ${status.vt_tray.length === 1 ? 'max-w-[50%]' : ''}`}>
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] text-white font-medium">{t('printers.external')}</span>
</div>
);
<div className={`grid ${status.vt_tray.length > 1 ? 'grid-cols-2' : 'grid-cols-1'} gap-1.5`}>
{[...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)).map((extTray) => {
const extTrayId = extTray.id ?? 254;
const isExtActive = effectiveTrayNow === extTrayId;
const slotTrayId = extTrayId - 254; // 0 or 1
const extLabel = isDualNozzle
? (extTrayId === 254 ? t('printers.extL') : t('printers.extR'))
: '';
const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
return (
<div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
{/* Row 1: Label */}
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] text-white font-medium">{t('printers.external')}</span>
</div>
{/* Row 2: Slot (full width since no stats) */}
<FilamentHoverCard
data={extFilamentData}
spoolman={{
enabled: spoolmanEnabled,
hasUnlinkedSpools,
linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
spoolmanUrl,
onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
tagUid: extFilamentData.tagUid || '',
trayUuid: uuid,
printerId: printer.id,
amsId: 255,
trayId: 0,
});
} : undefined,
}}
inventory={(() => {
const assignment = onGetAssignment?.(printer.id, 255, 0);
return {
assignedSpool: assignment?.spool ? {
id: assignment.spool.id,
material: assignment.spool.material,
brand: assignment.spool.brand,
color_name: assignment.spool.color_name,
} : null,
onAssignSpool: extFilamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
printerId: printer.id,
amsId: 255,
trayId: 0,
trayInfo: {
type: extFilamentData.profile,
color: extFilamentData.colorHex || '',
location: 'External Spool',
},
}) : undefined,
onUnassignSpool: assignment && extFilamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
};
})()}
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
amsId: 255, // External spool indicator
trayId: 0,
trayCount: 1, // External = single slot
trayType: extTray.tray_type || undefined,
trayColor: extTray.tray_color || undefined,
traySubBrands: extTray.tray_sub_brands || undefined,
trayInfoIdx: extTray.tray_info_idx || undefined,
}),
}}
>
{extSlotContent}
</FilamentHoverCard>
const extTrayTag = extTray.tray_uuid?.toUpperCase();
const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId);
const extInventoryFill = (() => {
const sp = extInventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
})();
const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
const extFilamentData = {
vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
colorHex: extTray.tray_color || null,
kFactor: formatKValue(extTray.k),
fillLevel: extEffectiveFill,
trayUuid: extTray.tray_uuid || null,
tagUid: extTray.tag_uid || null,
fillSource: extSpoolmanFill !== null ? 'spoolman' as const
: extInventoryFill !== null ? 'inventory' as const
: undefined,
};
const isEmpty = !extTray.tray_type;
const extSlotContent = (
<div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
style={{
backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : (extTray.tray_type ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
/>
<div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
{extTray.tray_type || '—'}
</div>
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
{extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty ? (
<div
className="h-full rounded-full transition-all"
style={{
width: `${extEffectiveFill}%`,
backgroundColor: getFillBarColor(extEffectiveFill),
}}
/>
) : !isEmpty ? (
<div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
) : null}
</div>
{extLabel && <div className="text-[7px] text-white/40 mt-0.5 truncate">{extLabel}</div>}
</div>
);
return (
<div key={extTrayId} className="relative group">
{!isEmpty ? (
<FilamentHoverCard
data={extFilamentData}
spoolman={{
enabled: spoolmanEnabled,
hasUnlinkedSpools,
linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
spoolmanUrl,
onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
tagUid: extFilamentData.tagUid || '',
trayUuid: uuid,
printerId: printer.id,
amsId: 255,
trayId: slotTrayId,
});
} : undefined,
}}
inventory={(() => {
const assignment = onGetAssignment?.(printer.id, 255, slotTrayId);
return {
assignedSpool: assignment?.spool ? {
id: assignment.spool.id,
material: assignment.spool.material,
brand: assignment.spool.brand,
color_name: assignment.spool.color_name,
} : null,
onAssignSpool: () => setAssignSpoolModal({
printerId: printer.id,
amsId: 255,
trayId: slotTrayId,
trayInfo: {
type: extFilamentData.profile,
color: extFilamentData.colorHex || '',
location: extLabel || t('printers.external'),
},
}),
onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, slotTrayId) : undefined,
};
})()}
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
amsId: 255,
trayId: slotTrayId,
trayCount: 1,
trayType: extTray.tray_type || undefined,
trayColor: extTray.tray_color || undefined,
traySubBrands: extTray.tray_sub_brands || undefined,
trayInfoIdx: extTray.tray_info_idx || undefined,
extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
caliIdx: extTray.cali_idx,
savedPresetId: extSlotPreset?.preset_id,
}),
}}
>
{extSlotContent}
</FilamentHoverCard>
) : (
<EmptySlotHoverCard
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
amsId: 255,
trayId: slotTrayId,
trayCount: 1,
extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
}),
}}
>
{extSlotContent}
</EmptySlotHoverCard>
)}
</div>
);
})}
</div>
);
})()}
</div>
)}
</div>
)}
</div>
@@ -3974,6 +4049,7 @@ function PrinterCard({
onClose={() => setConfigureSlotModal(null)}
printerId={printer.id}
slotInfo={configureSlotModal}
printerModel={mapModelCode(printer.model) || undefined}
onSuccess={() => {
// Refresh slot presets to show updated profile name
queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });
@@ -4128,43 +4204,7 @@ function AddPrinterModal({
}
};
// Map SSDP model codes to dropdown values
const mapModelCode = (ssdpModel: string | null): string => {
if (!ssdpModel) return '';
const modelMap: Record<string, string> = {
// H2 Series
'O1D': 'H2D',
'O1E': 'H2D Pro', // Some devices report O1E
'O2D': 'H2D Pro', // Some devices report O2D
'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',
'H2D Pro': 'H2D Pro',
'H2C': 'H2C',
'H2S': 'H2S',
};
return modelMap[ssdpModel] || ssdpModel;
};
// Reuse module-level mapModelCode
const selectPrinter = (printer: DiscoveredPrinter) => {
// Don't pre-fill serial if it's a placeholder (unknown-*) - user needs to enter actual serial
+1 -1
View File
@@ -88,7 +88,7 @@ export function getGlobalTrayId(
trayId: number,
isExternal: boolean
): number {
if (isExternal) return 254;
if (isExternal) return 254 + trayId;
// AMS-HT units have IDs starting at 128 with a single tray — use ID directly
if (amsId >= 128) return amsId;
return amsId * 4 + trayId;