mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user