mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Started wiring up AMS module
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -46,8 +46,15 @@ export interface AMSTray {
|
||||
id: number;
|
||||
tray_color: string | null;
|
||||
tray_type: string | null;
|
||||
tray_sub_brands: string | null; // Full name like "PLA Basic", "PETG HF"
|
||||
tray_id_name: string | null; // Bambu filament ID like "A00-Y2" (can decode to color)
|
||||
tray_info_idx: string | null; // Filament preset ID like "GFA00" - maps to cloud setting_id
|
||||
remain: number;
|
||||
k: number | null; // Pressure advance value
|
||||
tag_uid: string | null; // RFID tag UID (any tag)
|
||||
tray_uuid: string | null; // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
|
||||
nozzle_temp_min: number | null; // Min nozzle temperature
|
||||
nozzle_temp_max: number | null; // Max nozzle temperature
|
||||
}
|
||||
|
||||
export interface AMSUnit {
|
||||
@@ -124,6 +131,15 @@ export interface PrinterStatus {
|
||||
chamber_light: boolean;
|
||||
// Active extruder for dual nozzle (0=right, 1=left)
|
||||
active_extruder: number;
|
||||
// AMS mapping - which AMS is connected to which nozzle
|
||||
// Format: [ams_id_for_nozzle0, ams_id_for_nozzle1, ...] where -1 means no AMS
|
||||
ams_mapping: number[];
|
||||
// Per-AMS extruder mapping - extracted from each AMS unit's info field
|
||||
// Format: {ams_id: extruder_id} where extruder 0=right, 1=left
|
||||
// Note: JSON keys are always strings
|
||||
ams_extruder_map: Record<string, number>;
|
||||
// Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
|
||||
tray_now: number;
|
||||
}
|
||||
|
||||
export interface PrinterCreate {
|
||||
@@ -421,6 +437,34 @@ export interface KProfilesResponse {
|
||||
nozzle_diameter: string;
|
||||
}
|
||||
|
||||
// Slot Preset Mapping
|
||||
export interface SlotPresetMapping {
|
||||
ams_id: number;
|
||||
tray_id: number;
|
||||
preset_id: string;
|
||||
preset_name: string;
|
||||
}
|
||||
|
||||
// Filament types
|
||||
export interface Filament {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string; // PLA, PETG, ABS, etc.
|
||||
brand: string | null;
|
||||
color: string | null;
|
||||
color_hex: string | null;
|
||||
cost_per_kg: number;
|
||||
spool_weight_g: number;
|
||||
currency: string;
|
||||
density: number | null;
|
||||
print_temp_min: number | null;
|
||||
print_temp_max: number | null;
|
||||
bed_temp_min: number | null;
|
||||
bed_temp_max: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Notification Provider types
|
||||
export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email';
|
||||
|
||||
@@ -1036,6 +1080,25 @@ export const api = {
|
||||
body: JSON.stringify(profile),
|
||||
}),
|
||||
|
||||
// Slot Preset Mappings
|
||||
getSlotPresets: (printerId: number) =>
|
||||
request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
|
||||
getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
|
||||
request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
|
||||
saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string) =>
|
||||
request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}`, {
|
||||
method: 'PUT',
|
||||
}),
|
||||
deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
|
||||
request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// Filaments
|
||||
listFilaments: () => request<Filament[]>('/filaments/'),
|
||||
getFilament: (id: number) => request<Filament>(`/filaments/${id}`),
|
||||
getFilamentsByType: (type: string) => request<Filament[]>(`/filaments/by-type/${type}`),
|
||||
|
||||
// Notification Providers
|
||||
getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
|
||||
getNotificationProvider: (id: number) => request<NotificationProvider>(`/notifications/${id}`),
|
||||
@@ -1204,10 +1267,10 @@ export const api = {
|
||||
}),
|
||||
enableMotors: (printerId: number) =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/motors/enable`, { method: 'POST' }),
|
||||
amsLoadFilament: (printerId: number, trayId: number) =>
|
||||
amsLoadFilament: (printerId: number, trayId: number, extruderId?: number) =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/ams/load`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tray_id: trayId }),
|
||||
body: JSON.stringify({ tray_id: trayId, extruder_id: extruderId }),
|
||||
}),
|
||||
amsUnloadFilament: (printerId: number) =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/ams/unload`, { method: 'POST' }),
|
||||
@@ -1239,6 +1302,13 @@ export const api = {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Refresh a specific AMS tray (trigger RFID re-read)
|
||||
refreshAmsTray: (printerId: number, amsId: number, trayId: number) =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/ams/refresh-tray`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ams_id: amsId, tray_id: trayId }),
|
||||
}),
|
||||
|
||||
// Print Options (AI Detection)
|
||||
setPrintOption: (
|
||||
printerId: number,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface AMSHumidityModalProps {
|
||||
humidity: number;
|
||||
temperature: number;
|
||||
dryingStatus: 'idle' | 'drying';
|
||||
remainingTime?: number; // in minutes
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Single humidity icon that fills based on level (larger version for modal)
|
||||
function HumidityIconLarge({ humidity }: { humidity: number }) {
|
||||
const getIconSrc = (): string => {
|
||||
if (humidity < 25) return '/icons/humidity-empty.svg';
|
||||
if (humidity < 40) return '/icons/humidity-half.svg';
|
||||
return '/icons/humidity-full.svg';
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
src={getIconSrc()}
|
||||
alt=""
|
||||
className="w-16 h-24"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AMSHumidityModal({
|
||||
humidity,
|
||||
temperature,
|
||||
dryingStatus,
|
||||
remainingTime,
|
||||
onClose,
|
||||
}: AMSHumidityModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const formatRemainingTime = () => {
|
||||
if (dryingStatus === 'idle') return 'Idle';
|
||||
if (!remainingTime) return 'Drying...';
|
||||
const hours = Math.floor(remainingTime / 60);
|
||||
const mins = remainingTime % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-bambu-dark-secondary rounded-lg shadow-xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-bambu-dark-tertiary">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Current AMS humidity</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500 dark:text-bambu-gray" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Large humidity icon */}
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<HumidityIconLarge humidity={humidity} />
|
||||
<div className="flex items-center gap-2 mt-2 text-gray-600 dark:text-bambu-gray">
|
||||
<span className="text-yellow-500">❊</span>
|
||||
<span>{dryingStatus === 'idle' ? 'Idle' : 'Drying'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-bambu-gray mb-1">Humidity</div>
|
||||
<div className="text-lg font-medium text-gray-900 dark:text-white">{humidity}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-bambu-gray mb-1">Temperature</div>
|
||||
<div className="text-lg font-medium text-gray-900 dark:text-white">{temperature} °C</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-bambu-gray mb-1">Remaining Time</div>
|
||||
<div className="text-lg font-medium text-gray-900 dark:text-white">{formatRemainingTime()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, ExternalLink } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import type { AMSTray, KProfile } from '../../api/client';
|
||||
|
||||
interface AMSMaterialsModalProps {
|
||||
tray: AMSTray;
|
||||
amsId: number; // AMS unit ID (0, 1, 2, 3)
|
||||
slotLabel: string;
|
||||
printerId: number;
|
||||
printerModel: string; // e.g., "H2D", "X1C", "P1S"
|
||||
nozzleDiameter?: string;
|
||||
onClose: () => void;
|
||||
onConfirm?: (data: MaterialSettings) => void;
|
||||
}
|
||||
|
||||
// Extract base filament name (without printer/nozzle suffix)
|
||||
// e.g., "# Bambu PLA Basic @BBL H2D" -> "# Bambu PLA Basic"
|
||||
// e.g., "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" -> "Devil Design PLA Basic"
|
||||
function getBaseFilamentName(name: string): string {
|
||||
const atIndex = name.indexOf(' @');
|
||||
return atIndex > 0 ? name.substring(0, atIndex) : name;
|
||||
}
|
||||
|
||||
// Get normalized name for deduplication (without # prefix and @ suffix)
|
||||
// e.g., "# Bambu PLA Basic @BBL H2D" -> "Bambu PLA Basic"
|
||||
// e.g., "Bambu PLA Basic @BBL X1C" -> "Bambu PLA Basic"
|
||||
function getNormalizedName(name: string): string {
|
||||
let normalized = name;
|
||||
if (normalized.startsWith('# ')) {
|
||||
normalized = normalized.substring(2);
|
||||
}
|
||||
const atIndex = normalized.indexOf(' @');
|
||||
return atIndex > 0 ? normalized.substring(0, atIndex) : normalized;
|
||||
}
|
||||
|
||||
// Get clean name for K-profile matching (without # prefix and @ suffix)
|
||||
function getCleanFilamentName(name: string): string {
|
||||
let cleanName = name;
|
||||
if (cleanName.startsWith('# ')) {
|
||||
cleanName = cleanName.substring(2);
|
||||
}
|
||||
const atIndex = cleanName.indexOf(' @');
|
||||
return atIndex > 0 ? cleanName.substring(0, atIndex) : cleanName;
|
||||
}
|
||||
|
||||
export interface MaterialSettings {
|
||||
filamentType: string;
|
||||
color: string;
|
||||
nozzleTempMax: number;
|
||||
nozzleTempMin: number;
|
||||
kProfile: string | null;
|
||||
kValue: number;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string | null): string {
|
||||
if (!hex) return 'rgb(128, 128, 128)';
|
||||
const cleanHex = hex.replace('#', '').substring(0, 6);
|
||||
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
|
||||
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
|
||||
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function trayColorToHex(trayColor: string | null): string {
|
||||
if (!trayColor) return '#808080';
|
||||
// Tray color comes as RRGGBBAA, we need #RRGGBB
|
||||
const cleanHex = trayColor.replace('#', '').substring(0, 6);
|
||||
return `#${cleanHex}`;
|
||||
}
|
||||
|
||||
// Check if tray has valid Bambu Lab UUID (32-char hex)
|
||||
function isBambuLabSpool(trayUuid: string | null): boolean {
|
||||
if (!trayUuid) return false;
|
||||
const uuid = trayUuid.trim();
|
||||
if (uuid.length !== 32) return false;
|
||||
if (uuid === '00000000000000000000000000000000') return false;
|
||||
return /^[0-9a-fA-F]{32}$/.test(uuid);
|
||||
}
|
||||
|
||||
// Bambu Lab color codes from tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow")
|
||||
const BAMBU_COLOR_CODES: Record<string, string> = {
|
||||
'Y2': 'Sunflower Yellow', 'Y0': 'Yellow', 'Y1': 'Lemon Yellow',
|
||||
'K0': 'Black', 'W0': 'White', 'W1': 'Ivory White',
|
||||
'R0': 'Red', 'R1': 'Scarlet Red', 'R2': 'Magenta',
|
||||
'B0': 'Blue', 'B1': 'Navy Blue', 'B2': 'Sky Blue', 'B3': 'Cyan',
|
||||
'G0': 'Green', 'G1': 'Grass Green', 'G2': 'Jade Green',
|
||||
'O0': 'Orange', 'O1': 'Mandarin Orange',
|
||||
'P0': 'Purple', 'P1': 'Pink', 'P2': 'Sakura Pink',
|
||||
'N0': 'Gray', 'N1': 'Silver Gray', 'N2': 'Charcoal',
|
||||
'D0': 'Brown', 'D1': 'Chocolate',
|
||||
'T0': 'Titan Gray', 'T1': 'Jade White',
|
||||
};
|
||||
|
||||
function getColorNameFromTrayId(trayIdName: string | null): string | null {
|
||||
if (!trayIdName) return null;
|
||||
// tray_id_name format: "A00-Y2" or "G02-K0" - color code is after the dash
|
||||
const parts = trayIdName.split('-');
|
||||
if (parts.length < 2) return null;
|
||||
const colorCode = parts[1];
|
||||
return BAMBU_COLOR_CODES[colorCode] || null;
|
||||
}
|
||||
|
||||
// Find best matching K-profile for a filament
|
||||
// Profiles have names like "HF Bambu PLA Basic Sunflower Yellow", "High Flow_Bambu PLA Basic"
|
||||
// tray_sub_brands is like "PLA Basic", "PETG HF"
|
||||
// tray_type is like "PLA", "PETG"
|
||||
// colorName is like "Sunflower Yellow"
|
||||
function findBestKProfile(
|
||||
profiles: KProfile[],
|
||||
traySubBrands: string | null,
|
||||
trayType: string | null,
|
||||
colorName: string | null
|
||||
): KProfile | null {
|
||||
if (!profiles.length) return null;
|
||||
|
||||
const subBrands = traySubBrands?.toLowerCase() || '';
|
||||
const type = trayType?.toUpperCase() || '';
|
||||
const color = colorName?.toLowerCase() || '';
|
||||
|
||||
// Priority 1: Match tray_sub_brands AND color (e.g., "PLA Basic" + "Sunflower Yellow")
|
||||
if (subBrands && color) {
|
||||
const exactColorMatch = profiles.find(p => {
|
||||
const name = p.name.toLowerCase();
|
||||
return name.includes(subBrands) && name.includes(color);
|
||||
});
|
||||
if (exactColorMatch) return exactColorMatch;
|
||||
}
|
||||
|
||||
// Priority 2: Match tray_sub_brands without color (e.g., "PLA Basic" in "High Flow_Bambu PLA Basic")
|
||||
if (subBrands) {
|
||||
const subBrandsMatch = profiles.find(p =>
|
||||
p.name.toLowerCase().includes(subBrands)
|
||||
);
|
||||
if (subBrandsMatch) return subBrandsMatch;
|
||||
}
|
||||
|
||||
// Priority 3: Match filament type in profile name (e.g., "PLA" in "High Flow_Bambu PLA Basic")
|
||||
if (type) {
|
||||
const typeMatches = profiles.filter(p =>
|
||||
p.name.toUpperCase().includes(type)
|
||||
);
|
||||
|
||||
if (typeMatches.length > 0) {
|
||||
// Prefer "Basic" profiles for generic type matching
|
||||
const basicMatch = typeMatches.find(p =>
|
||||
p.name.toLowerCase().includes('basic')
|
||||
);
|
||||
if (basicMatch) return basicMatch;
|
||||
|
||||
return typeMatches[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Default profile
|
||||
const defaultProfile = profiles.find(p =>
|
||||
p.name.toLowerCase() === 'default' || p.slot_id === 0
|
||||
);
|
||||
return defaultProfile || null;
|
||||
}
|
||||
|
||||
export function AMSMaterialsModal({
|
||||
tray,
|
||||
amsId,
|
||||
slotLabel,
|
||||
printerId,
|
||||
printerModel,
|
||||
nozzleDiameter = '0.4',
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: AMSMaterialsModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Determine slot type
|
||||
const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE';
|
||||
const isBambuSpool = isBambuLabSpool(tray.tray_uuid);
|
||||
const isEditable = isEmpty || !isBambuSpool;
|
||||
|
||||
// Fetch K-profiles
|
||||
const { data: kProfilesData } = useQuery({
|
||||
queryKey: ['kprofiles', printerId, nozzleDiameter],
|
||||
queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
|
||||
});
|
||||
|
||||
// Fetch cloud filament presets
|
||||
const { data: cloudSettings } = useQuery({
|
||||
queryKey: ['cloudSettings'],
|
||||
queryFn: () => api.getCloudSettings(),
|
||||
});
|
||||
|
||||
// Fetch saved slot preset mapping
|
||||
const { data: savedPreset } = useQuery({
|
||||
queryKey: ['slotPreset', printerId, amsId, tray.id],
|
||||
queryFn: () => api.getSlotPreset(printerId, amsId, tray.id),
|
||||
enabled: isEditable, // Only fetch for editable (non-Bambu) slots
|
||||
});
|
||||
|
||||
// Mutation for saving preset mapping
|
||||
const savePresetMutation = useMutation({
|
||||
mutationFn: ({ presetId, presetName }: { presetId: string; presetName: string }) =>
|
||||
api.saveSlotPreset(printerId, amsId, tray.id, presetId, presetName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['slotPreset', printerId, amsId, tray.id] });
|
||||
},
|
||||
});
|
||||
|
||||
// Get filament presets from cloud settings:
|
||||
// 1. Show ALL filament presets (like Bambu Studio does in cloud profiles)
|
||||
// 2. Prioritize current printer model, then deduplicate by base name
|
||||
// 3. Sort alphabetically
|
||||
const filamentPresets = (() => {
|
||||
const allPresets = cloudSettings?.filament || [];
|
||||
|
||||
// Sort presets: current printer model first, then others
|
||||
const modelPattern = new RegExp(`@.*\\b${printerModel}\\b`, 'i');
|
||||
const sortedByModel = [...allPresets].sort((a, b) => {
|
||||
const aMatches = modelPattern.test(a.name);
|
||||
const bMatches = modelPattern.test(b.name);
|
||||
if (aMatches && !bMatches) return -1;
|
||||
if (!aMatches && bMatches) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Remove duplicates - keep first occurrence of each normalized name
|
||||
// (presets for current printer model will be kept due to sorting above)
|
||||
// Uses normalized name (without # prefix) so "# Bambu ABS" and "Bambu ABS" are treated as same
|
||||
const seenNormalizedNames = new Set<string>();
|
||||
const unique = sortedByModel.filter(p => {
|
||||
const normalizedName = getNormalizedName(p.name);
|
||||
if (seenNormalizedNames.has(normalizedName)) return false;
|
||||
seenNormalizedNames.add(normalizedName);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by base name alphabetically
|
||||
const sorted = unique.sort((a, b) =>
|
||||
getBaseFilamentName(a.name).localeCompare(getBaseFilamentName(b.name))
|
||||
);
|
||||
|
||||
return sorted;
|
||||
})();
|
||||
|
||||
// State
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
||||
const [selectedPresetName, setSelectedPresetName] = useState<string | null>(null);
|
||||
const [selectedFilamentType, setSelectedFilamentType] = useState(tray.tray_type || '');
|
||||
const [selectedColor, setSelectedColor] = useState(trayColorToHex(tray.tray_color));
|
||||
const [nozzleTempMax, setNozzleTempMax] = useState(tray.nozzle_temp_max || 220);
|
||||
const [nozzleTempMin, setNozzleTempMin] = useState(tray.nozzle_temp_min || 190);
|
||||
const [selectedKProfile, setSelectedKProfile] = useState<string>('');
|
||||
const [kValue, setKValue] = useState(tray.k || 0);
|
||||
|
||||
// Load saved preset when data is available
|
||||
useEffect(() => {
|
||||
if (savedPreset && !selectedPresetId) {
|
||||
setSelectedPresetId(savedPreset.preset_id);
|
||||
setSelectedPresetName(savedPreset.preset_name);
|
||||
}
|
||||
}, [savedPreset, selectedPresetId]);
|
||||
|
||||
// Handle filament preset selection from dropdown
|
||||
const handlePresetSelect = (settingId: string) => {
|
||||
if (!settingId) {
|
||||
setSelectedPresetId(null);
|
||||
setSelectedPresetName(null);
|
||||
return;
|
||||
}
|
||||
setSelectedPresetId(settingId);
|
||||
const preset = filamentPresets.find(f => f.setting_id === settingId);
|
||||
if (preset) {
|
||||
setSelectedPresetName(preset.name);
|
||||
// Extract filament type from preset name (e.g., "Devil Design PLA Basic @BBL X1C" -> look for PLA, PETG, etc.)
|
||||
const typeMatch = preset.name.match(/\b(PLA|PETG|ABS|TPU|ASA|PA|PC|PVA|HIPS|PLA-S|PETG-CF|PA-CF|PET-CF)\b/i);
|
||||
if (typeMatch) {
|
||||
setSelectedFilamentType(typeMatch[1].toUpperCase());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Find best K-profile when data loads or filament type changes
|
||||
const profiles = kProfilesData?.profiles || [];
|
||||
|
||||
// Get color name from Bambu tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow")
|
||||
const bambuColorName = getColorNameFromTrayId(tray.tray_id_name);
|
||||
|
||||
useEffect(() => {
|
||||
// For Bambu spools, use tray_sub_brands (e.g., "PLA Basic") and color name
|
||||
// For non-Bambu, use clean name from preset (e.g., "Bambu PLA Basic" without #) for K-profile matching
|
||||
const subBrands = isBambuSpool
|
||||
? tray.tray_sub_brands
|
||||
: (selectedPresetName ? getCleanFilamentName(selectedPresetName) : null);
|
||||
const type = isBambuSpool ? tray.tray_type : (selectedFilamentType || tray.tray_type);
|
||||
const colorName = isBambuSpool ? bambuColorName : null;
|
||||
|
||||
const bestProfile = findBestKProfile(profiles, subBrands, type, colorName);
|
||||
|
||||
if (bestProfile) {
|
||||
setSelectedKProfile(bestProfile.name);
|
||||
setKValue(parseFloat(bestProfile.k_value) || 0);
|
||||
} else {
|
||||
setSelectedKProfile('Default');
|
||||
setKValue(0);
|
||||
}
|
||||
}, [profiles, selectedFilamentType, selectedPresetName, tray.tray_type, tray.tray_sub_brands, tray.tray_id_name, isBambuSpool, bambuColorName]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleKProfileChange = (profileName: string) => {
|
||||
setSelectedKProfile(profileName);
|
||||
const profile = profiles.find(p => p.name === profileName);
|
||||
if (profile) {
|
||||
setKValue(parseFloat(profile.k_value) || 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
// Save preset mapping for non-Bambu slots
|
||||
if (isEditable && selectedPresetId && selectedPresetName) {
|
||||
savePresetMutation.mutate({
|
||||
presetId: selectedPresetId,
|
||||
presetName: selectedPresetName,
|
||||
});
|
||||
}
|
||||
|
||||
onConfirm?.({
|
||||
filamentType: selectedFilamentType,
|
||||
color: selectedColor,
|
||||
nozzleTempMax,
|
||||
nozzleTempMin,
|
||||
kProfile: selectedKProfile,
|
||||
kValue,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedFilamentType(tray.tray_type || '');
|
||||
setSelectedColor(trayColorToHex(tray.tray_color));
|
||||
setNozzleTempMax(tray.nozzle_temp_max || 220);
|
||||
setNozzleTempMin(tray.nozzle_temp_min || 190);
|
||||
setKValue(tray.k || 0);
|
||||
};
|
||||
|
||||
const filamentDisplayName = isBambuSpool
|
||||
? (tray.tray_sub_brands || tray.tray_type || 'Unknown')
|
||||
: (selectedFilamentType || 'Select filament');
|
||||
|
||||
const serialNumber = isBambuSpool && tray.tray_uuid
|
||||
? tray.tray_uuid
|
||||
: 'N/A';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-bambu-dark-secondary rounded-lg shadow-xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-bambu-dark-tertiary">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
AMS Materials Setting - {slotLabel}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500 dark:text-bambu-gray" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Status indicator */}
|
||||
{isBambuSpool && (
|
||||
<div className="flex items-center gap-2 text-xs text-bambu-green bg-bambu-green/10 px-3 py-1.5 rounded-md">
|
||||
<span className="w-2 h-2 bg-bambu-green rounded-full"></span>
|
||||
Bambu Lab RFID detected - fields auto-filled
|
||||
</div>
|
||||
)}
|
||||
{!isBambuSpool && !isEmpty && (
|
||||
<div className="flex items-center gap-2 text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/20 px-3 py-1.5 rounded-md">
|
||||
<span className="w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
Non-Bambu Lab filament - please verify settings
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-bambu-gray bg-gray-100 dark:bg-bambu-dark px-3 py-1.5 rounded-md">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full"></span>
|
||||
Empty slot - configure filament settings
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filament */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">Filament</label>
|
||||
{isEditable ? (
|
||||
<select
|
||||
value={selectedPresetId || ''}
|
||||
onChange={(e) => handlePresetSelect(e.target.value)}
|
||||
className="flex-1 min-w-0 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white text-sm truncate"
|
||||
>
|
||||
<option value="">Select filament...</option>
|
||||
{filamentPresets.length > 0 ? (
|
||||
filamentPresets.map(preset => (
|
||||
<option key={preset.setting_id} value={preset.setting_id}>
|
||||
{getBaseFilamentName(preset.name)}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="" disabled>No filament presets for {printerModel}</option>
|
||||
)}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md text-gray-900 dark:text-white">
|
||||
{filamentDisplayName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">Color</label>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{isEditable ? (
|
||||
<>
|
||||
<input
|
||||
type="color"
|
||||
value={selectedColor}
|
||||
onChange={(e) => setSelectedColor(e.target.value)}
|
||||
className="w-10 h-10 rounded cursor-pointer border-2 border-gray-300 dark:border-bambu-dark-tertiary"
|
||||
/>
|
||||
<span className="text-gray-900 dark:text-white text-sm">{selectedColor.toUpperCase()}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-bambu-dark-tertiary flex-shrink-0"
|
||||
style={{ backgroundColor: hexToRgb(tray.tray_color) }}
|
||||
/>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{bambuColorName || trayColorToHex(tray.tray_color).toUpperCase()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nozzle Temperature */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">Nozzle Temp</label>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-bambu-gray mb-1">min</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={nozzleTempMin}
|
||||
onChange={(e) => setNozzleTempMin(Number(e.target.value))}
|
||||
disabled={!isEditable}
|
||||
className="w-20 px-2 py-1 bg-gray-100 dark:bg-bambu-dark rounded border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-gray-600 dark:text-bambu-gray">°C</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-bambu-gray mb-1">max</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={nozzleTempMax}
|
||||
onChange={(e) => setNozzleTempMax(Number(e.target.value))}
|
||||
disabled={!isEditable}
|
||||
className="w-20 px-2 py-1 bg-gray-100 dark:bg-bambu-dark rounded border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-gray-600 dark:text-bambu-gray">°C</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SN */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">SN</label>
|
||||
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md text-gray-900 dark:text-white text-xs font-mono truncate">
|
||||
{serialNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Dynamics Calibration */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-bambu-dark-tertiary">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Flow Dynamics Calibration{' '}
|
||||
<a
|
||||
href="https://wiki.bambulab.com/en/software/bambu-studio/calibration_pa"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bambu-green hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Wiki
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{/* K Profile */}
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<label className="w-28 text-sm text-gray-600 dark:text-bambu-gray flex-shrink-0">K Profile</label>
|
||||
<select
|
||||
value={selectedKProfile}
|
||||
onChange={(e) => handleKProfileChange(e.target.value)}
|
||||
className="flex-1 min-w-0 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md border border-gray-300 dark:border-bambu-dark-tertiary text-gray-900 dark:text-white text-sm truncate"
|
||||
>
|
||||
<option value="Default">Default</option>
|
||||
{profiles.map(profile => (
|
||||
<option key={profile.slot_id} value={profile.name}>
|
||||
{profile.name} (K={parseFloat(profile.k_value).toFixed(3)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Factor K */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-28 text-sm text-gray-600 dark:text-bambu-gray">Factor K</label>
|
||||
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-bambu-dark rounded-md text-gray-900 dark:text-white">
|
||||
{kValue.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-center gap-3 p-4 border-t border-gray-200 dark:border-bambu-dark-tertiary">
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="px-6 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green-dark transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-2 bg-gray-200 dark:bg-bambu-dark text-gray-700 dark:text-bambu-gray rounded-md hover:bg-gray-300 dark:hover:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-200 dark:bg-bambu-dark text-gray-700 dark:text-bambu-gray rounded-md hover:bg-gray-300 dark:hover:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -255,6 +255,7 @@ export function ControlPage() {
|
||||
{/* AMS Section */}
|
||||
<AMSSectionDual
|
||||
printerId={selectedPrinter.id}
|
||||
printerModel={selectedPrinter.model || 'X1C'}
|
||||
status={selectedStatus}
|
||||
nozzleCount={selectedPrinter.nozzle_count}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user