diff --git a/frontend/public/icons/extruder-change-filament.png b/frontend/public/icons/extruder-change-filament.png new file mode 100644 index 000000000..c709c2b51 Binary files /dev/null and b/frontend/public/icons/extruder-change-filament.png differ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 73247e1f8..13a4cbee9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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; + // 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>(`/printers/${printerId}/slot-presets`), + getSlotPreset: (printerId: number, amsId: number, trayId: number) => + request(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`), + saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string) => + request(`/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('/filaments/'), + getFilament: (id: number) => request(`/filaments/${id}`), + getFilamentsByType: (type: string) => request(`/filaments/by-type/${type}`), + // Notification Providers getNotificationProviders: () => request('/notifications/'), getNotificationProvider: (id: number) => request(`/notifications/${id}`), @@ -1204,10 +1267,10 @@ export const api = { }), enableMotors: (printerId: number) => request(`/printers/${printerId}/control/motors/enable`, { method: 'POST' }), - amsLoadFilament: (printerId: number, trayId: number) => + amsLoadFilament: (printerId: number, trayId: number, extruderId?: number) => request(`/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(`/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(`/printers/${printerId}/control/ams/refresh-tray`, { + method: 'POST', + body: JSON.stringify({ ams_id: amsId, tray_id: trayId }), + }), + // Print Options (AI Detection) setPrintOption: ( printerId: number, diff --git a/frontend/src/components/control/AMSHumidityModal.tsx b/frontend/src/components/control/AMSHumidityModal.tsx new file mode 100644 index 000000000..5640abfec --- /dev/null +++ b/frontend/src/components/control/AMSHumidityModal.tsx @@ -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 ( + + ); +} + +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 ( +
+
+ {/* Header */} +
+

Current AMS humidity

+ +
+ + {/* Content */} +
+ {/* Large humidity icon */} +
+ +
+ ❊ + {dryingStatus === 'idle' ? 'Idle' : 'Drying'} +
+
+ + {/* Stats row */} +
+
+
Humidity
+
{humidity}%
+
+
+
Temperature
+
{temperature} °C
+
+
+
Remaining Time
+
{formatRemainingTime()}
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/control/AMSMaterialsModal.tsx b/frontend/src/components/control/AMSMaterialsModal.tsx new file mode 100644 index 000000000..6cf1596df --- /dev/null +++ b/frontend/src/components/control/AMSMaterialsModal.tsx @@ -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 = { + '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(); + 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(null); + const [selectedPresetName, setSelectedPresetName] = useState(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(''); + 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 ( +
+
+ {/* Header */} +
+

+ AMS Materials Setting - {slotLabel} +

+ +
+ + {/* Content */} +
+ {/* Status indicator */} + {isBambuSpool && ( +
+ + Bambu Lab RFID detected - fields auto-filled +
+ )} + {!isBambuSpool && !isEmpty && ( +
+ + Non-Bambu Lab filament - please verify settings +
+ )} + {isEmpty && ( +
+ + Empty slot - configure filament settings +
+ )} + + {/* Filament */} +
+ + {isEditable ? ( + + ) : ( +
+ {filamentDisplayName} +
+ )} +
+ + {/* Color */} +
+ +
+ {isEditable ? ( + <> + setSelectedColor(e.target.value)} + className="w-10 h-10 rounded cursor-pointer border-2 border-gray-300 dark:border-bambu-dark-tertiary" + /> + {selectedColor.toUpperCase()} + + ) : ( + <> +
+ + {bambuColorName || trayColorToHex(tray.tray_color).toUpperCase()} + + + )} +
+
+ + {/* Nozzle Temperature */} +
+ +
+
+
min
+
+ 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" + /> + °C +
+
+
+
max
+
+ 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" + /> + °C +
+
+
+
+ + {/* SN */} +
+ +
+ {serialNumber} +
+
+ + {/* Flow Dynamics Calibration */} +
+

+ Flow Dynamics Calibration{' '} + + Wiki + + +

+ + {/* K Profile */} +
+ + +
+ + {/* Factor K */} +
+ +
+ {kValue.toFixed(3)} +
+
+
+
+ + {/* Footer */} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/control/AMSSectionDual.tsx b/frontend/src/components/control/AMSSectionDual.tsx index dc549fe2f..7ce97b79b 100644 --- a/frontend/src/components/control/AMSSectionDual.tsx +++ b/frontend/src/components/control/AMSSectionDual.tsx @@ -1,11 +1,20 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useMutation } from '@tanstack/react-query'; import { api } from '../../api/client'; -import type { PrinterStatus, AMSUnit } from '../../api/client'; -import { Loader2 } from 'lucide-react'; +import type { PrinterStatus, AMSUnit, AMSTray } from '../../api/client'; +import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react'; +import { AMSHumidityModal } from './AMSHumidityModal'; +import { AMSMaterialsModal } from './AMSMaterialsModal'; + +// Filament change stages from MQTT stg_cur +const STAGE_HEATING_NOZZLE = 7; +const STAGE_FILAMENT_UNLOADING = 22; +const STAGE_FILAMENT_LOADING = 24; +const STAGE_CHANGING_FILAMENT = 4; interface AMSSectionDualProps { printerId: number; + printerModel: string; status: PrinterStatus | null | undefined; nozzleCount: number; } @@ -49,6 +58,144 @@ function HumidityIcon({ humidity }: { humidity: number }) { ); } +// Filament change progress card - appears during load/unload operations +interface FilamentChangeCardProps { + isLoading: boolean; // true = loading, false = unloading + currentStage: number; + onRetry?: () => void; +} + +interface StepInfo { + label: string; + status: 'completed' | 'in_progress' | 'pending'; + stepNumber: number; +} + +function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + + // Determine step status based on current stage + // When stage is -1 (initial/waiting), show first step as in_progress + const getLoadingSteps = (): StepInfo[] => { + // Loading sequence: Heat nozzle (7) -> Push filament (24) -> Purge (still 24 or complete) + let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + + if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { + // Initial state or heating - step 1 is active + step1Status = 'in_progress'; + } else if (currentStage === STAGE_FILAMENT_LOADING) { + // Loading filament - step 1 done, step 2 active + step1Status = 'completed'; + step2Status = 'in_progress'; + } + + return [ + { label: 'Heat the nozzle', stepNumber: 1, status: step1Status }, + { label: 'Push new filament into extruder', stepNumber: 2, status: step2Status }, + { label: 'Purge old filament', stepNumber: 3, status: step3Status }, + ]; + }; + + const getUnloadingSteps = (): StepInfo[] => { + let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending'; + + if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) { + // Initial state or heating - step 1 is active + step1Status = 'in_progress'; + } else if (currentStage === STAGE_FILAMENT_UNLOADING) { + // Unloading filament - step 1 done, step 2 active + step1Status = 'completed'; + step2Status = 'in_progress'; + } + + return [ + { label: 'Heat the nozzle', stepNumber: 1, status: step1Status }, + { label: 'Retract filament from extruder', stepNumber: 2, status: step2Status }, + ]; + }; + + const steps = isLoading ? getLoadingSteps() : getUnloadingSteps(); + const title = isLoading ? 'Loading' : 'Unloading'; + const headerText = isLoading ? 'Filament loading...' : 'Filament unloading...'; + + return ( +
+ {/* Collapsible header */} + + + {/* Content */} + {!isCollapsed && ( +
+
+ {/* Steps list */} +
+

{title}

+
+ {steps.map((step) => ( +
+ {/* Step indicator */} + {step.status === 'completed' ? ( +
+ + + +
+ ) : step.status === 'in_progress' ? ( +
+ {step.stepNumber} +
+ ) : ( +
+ {step.stepNumber} +
+ )} + {/* Step label */} + + {step.label} + +
+ ))} +
+
+ + {/* Extruder image */} +
+ Extruder +
+
+ + {/* Retry button */} + {onRetry && ( + + )} +
+ )} +
+ ); +} + interface AMSPanelContentProps { units: AMSUnit[]; side: 'left' | 'right'; @@ -57,9 +204,35 @@ interface AMSPanelContentProps { onSelectAms: (index: number) => void; selectedTray: number | null; onSelectTray: (trayId: number | null) => void; + onHumidityClick: (humidity: number, temp: number) => void; + onSlotRefresh: (amsId: number, slotId: number) => void; + onEyeClick: (tray: AMSTray, slotLabel: string, amsId: number) => void; } // Panel content - NO wiring, just slots and info +// Get slot label based on AMS unit ID and tray index +// Regular AMS (ID 0-3): A1, A2, A3, A4 / B1, B2, B3, B4 / etc. +// AMS-HT (ID >= 128): HT-A, HT-B (for first HT unit), HT2-A, HT2-B (for second), etc. +function getSlotLabel(amsId: number, trayIndex: number): string { + if (amsId >= 128) { + // AMS-HT unit - uses HT-A, HT-B naming + const htUnitNumber = amsId - 128; // 0 for first HT, 1 for second, etc. + const slotLetter = String.fromCharCode(65 + trayIndex); // A, B + if (htUnitNumber === 0) { + return `HT-${slotLetter}`; + } + return `HT${htUnitNumber + 1}-${slotLetter}`; + } + // Regular AMS - uses A1, B2, etc. naming + const prefix = String.fromCharCode(65 + amsId); // 65 is ASCII for 'A' + return `${prefix}${trayIndex + 1}`; +} + +// Check if AMS unit is an AMS-HT (ID >= 128) +function isAmsHT(amsId: number): boolean { + return amsId >= 128; +} + function AMSPanelContent({ units, side, @@ -68,22 +241,25 @@ function AMSPanelContent({ onSelectAms, selectedTray, onSelectTray, + onHumidityClick, + onSlotRefresh, + onEyeClick, }: AMSPanelContentProps) { const selectedUnit = units[selectedAmsIndex]; - const slotPrefix = side === 'left' ? 'A' : 'B'; + const isHT = selectedUnit ? isAmsHT(selectedUnit.id) : false; return (
{/* AMS Tab Selectors */} -
+
{units.map((unit, index) => (
+ {/* Slot Labels */} -
- {selectedUnit.tray.map((tray, index) => ( -
- {slotPrefix}{index + 1} - -
- ))} +
+ {selectedUnit.tray.map((tray, index) => { + const slotLabel = getSlotLabel(selectedUnit.id, index); + return ( + + ); + })}
{/* AMS Slots - NO wiring here */} -
- {selectedUnit.tray.map((tray) => { +
+ {selectedUnit.tray.map((tray, index) => { const globalTrayId = selectedUnit.id * 4 + tray.id; const isSelected = selectedTray === globalTrayId; const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE'; const isLight = isLightColor(tray.tray_color); + const slotLabel = getSlotLabel(selectedUnit.id, index); return ( - )}
- +
); })}
@@ -194,117 +414,487 @@ function AMSPanelContent({ // Unified wiring layer - draws ALL wiring in one place interface WiringLayerProps { isDualNozzle: boolean; + leftSlotCount: number; // Number of slots on left panel (4 for regular AMS, 1-2 for AMS-HT) + rightSlotCount: number; // Number of slots on right panel + leftIsHT: boolean; // Is left panel an AMS-HT + rightIsHT: boolean; // Is right panel an AMS-HT + leftActiveSlot?: number | null; // Currently active slot index on left panel (0-3) + rightActiveSlot?: number | null; // Currently active slot index on right panel (0-3) + leftFilamentColor?: string | null; // Filament color for left active path + rightFilamentColor?: string | null; // Filament color for right active path } -function WiringLayer({ isDualNozzle }: WiringLayerProps) { +function WiringLayer({ + isDualNozzle, + leftSlotCount, + rightSlotCount, + leftIsHT, + rightIsHT, + leftActiveSlot, + rightActiveSlot, + leftFilamentColor, + rightFilamentColor, +}: WiringLayerProps) { if (!isDualNozzle) return null; // All measurements relative to this container // Container spans full width between panels - // Left panel wiring: slots → hub → down → right → down to extruder - // Right panel wiring: slots → hub → down → left → down to extruder + // Regular AMS: slots → hub → down → toward center → down to extruder + // AMS-HT: single slot on left → direct line down to extruder + + // Regular AMS: Slots are w-14 (56px) with gap-2 (8px), 4 slots = 248px total, centered in each ~300px panel + // Left panel center ~150, slots start at 150 - 124 = 26 + // Slot centers: 26+28=54, 54+64=118, 118+64=182, 182+64=246 + + // AMS-HT: Left aligned with pl-2 (8px), slot starts at 8px + 28px = 36px center + // For 2 slots: 36, 100 (36 + 64) + + // Right panel calculations for regular AMS: + // Right panel center ~450, slots start at 450 - 124 = 326 + // Slot centers: 326+28=354, 354+64=418, 418+64=482, 482+64=546 + + // Right panel AMS-HT: Left aligned, starts at ~308 (300 panel offset + 8px padding) + // Slot center: 308 + 28 = 336 + + // Determine colors for wiring paths + const defaultColor = '#909090'; + const leftActiveColor = leftFilamentColor ? hexToRgb(leftFilamentColor) : null; + const rightActiveColor = rightFilamentColor ? hexToRgb(rightFilamentColor) : null; + + // Slot X positions for regular AMS (4 slots) + const leftSlotX = [54, 118, 182, 246]; + // Right slot positions + const rightSlotX = [354, 418, 482, 546]; return ( -
- {/* SVG for all wiring - single coordinate system */} - {/* Slots are w-14 (56px) with gap-2 (8px), 4 slots = 248px total, centered in each ~300px panel */} - {/* Left panel center ~150, slots start at 150 - 124 = 26 */} - {/* Slot centers: 26+28=54, 54+64=118, 118+64=182, 182+64=246 */} - {/* Right panel center ~450, slots start at 450 - 124 = 326 */} - {/* Slot centers: 326+28=354, 354+64=418, 418+64=482, 482+64=546 */} +
{/* Left panel wiring */} - {/* Vertical lines from 4 slots */} - - - - + {leftIsHT ? ( + <> + {/* AMS-HT: Simple direct line from slot to extruder */} + {/* Slot vertical lines - highlight active slot */} + + {leftSlotCount > 1 && ( + + )} + {leftSlotCount > 1 && ( + + )} + {/* Path to extruder - always colored if filament loaded */} + 1 ? "68" : "36"} y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} /> + + + ) : ( + <> + {/* Regular AMS: 4 slots with hub */} + {/* Vertical lines from 4 slots - highlight active slot */} + {leftSlotX.map((x, i) => ( + + ))} - {/* Horizontal bar connecting left slots */} - + {/* Horizontal bar connecting left slots - highlight from active slot to hub */} + {leftActiveSlot !== null && leftActiveSlot !== undefined && leftActiveColor ? ( + <> + {/* Background bar */} + + {/* Highlight segment from active slot to hub (center at 150) */} + + + ) : ( + + )} - {/* Left hub */} - + {/* Left hub */} + - {/* Vertical from left hub down */} - + {/* Vertical from left hub down */} + - {/* Horizontal from left hub toward center */} - + {/* Horizontal from left hub toward center */} + - {/* Vertical down to left extruder inlet */} - + {/* Vertical down to left extruder inlet */} + + + )} {/* Right panel wiring */} - {/* Vertical lines from 4 slots */} - - - - + {rightIsHT ? ( + <> + {/* AMS-HT: Simple direct line from slot to extruder */} + + {rightSlotCount > 1 && ( + + )} + {rightSlotCount > 1 && ( + + )} + 1 ? "368" : "336"} y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} /> + + + ) : ( + <> + {/* Regular AMS: 4 slots with hub */} + {/* Vertical lines from 4 slots - highlight active slot */} + {rightSlotX.map((x, i) => ( + + ))} - {/* Horizontal bar connecting right slots */} - + {/* Horizontal bar connecting right slots - highlight from active slot to hub */} + {rightActiveSlot !== null && rightActiveSlot !== undefined && rightActiveColor ? ( + <> + {/* Background bar */} + + {/* Highlight segment from active slot to hub (center at 450) */} + + + ) : ( + + )} - {/* Right hub */} - + {/* Right hub */} + - {/* Vertical from right hub down */} - + {/* Vertical from right hub down */} + - {/* Horizontal from right hub toward center */} - + {/* Horizontal from right hub toward center */} + - {/* Vertical down to right extruder inlet */} - + {/* Vertical down to right extruder inlet */} + + + )} - {/* Extruder image - positioned at bottom center */} - Extruder + {/* Extruder image container - positioned at bottom center */} + {/* Image is 56x71 pixels, scaled to h=50px = width ~39px */} + {/* Scale factor: 50/71 = 0.704 */} + {/* Green circles in original image: left center ~(15.2,34.2), right center ~(41.0,33.9) */} + {/* Scaled positions: left x≈10.7, right x≈28.9, y≈24 from top */} +
+ Extruder + {/* Extruder inlet indicator circles - overlay on extruder image */} + {/* Left inlet (extruder 1) - left side of extruder */} +
+ {/* Right inlet (extruder 0) - right side of extruder */} +
+
); } -export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDualProps) { +export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }: AMSSectionDualProps) { const isConnected = status?.connected ?? false; const isPrinting = status?.state === 'RUNNING'; const isDualNozzle = nozzleCount > 1; const amsUnits: AMSUnit[] = status?.ams ?? []; + // Per-AMS extruder map: {ams_id: extruder_id} where extruder 0=right, 1=left + // This is extracted from each AMS unit's info field bit 8 in the backend + // Note: JSON keys are always strings, so we use Record + const amsExtruderMap: Record = status?.ams_extruder_map ?? {}; - const leftUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 0) : amsUnits; - const rightUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 1) : []; + // Distribute AMS units based on ams_extruder_map + // Each AMS unit's info field tells us which extruder it's connected to: + // extruder 0 = right nozzle, extruder 1 = left nozzle + const leftUnits = (() => { + if (!isDualNozzle) return amsUnits; + if (Object.keys(amsExtruderMap).length > 0) { + // Filter AMS units assigned to extruder 1 (left nozzle) + // JSON keys are strings, so convert unit.id to string + return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1); + } + // Fallback: odd indices go to left (extruder 1) + return amsUnits.filter((_, i) => i % 2 === 1); + })(); + + const rightUnits = (() => { + if (!isDualNozzle) return []; + if (Object.keys(amsExtruderMap).length > 0) { + // Filter AMS units assigned to extruder 0 (right nozzle) + // JSON keys are strings, so convert unit.id to string + return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0); + } + // Fallback: even indices go to right (extruder 0) + return amsUnits.filter((_, i) => i % 2 === 0); + })(); const [leftAmsIndex, setLeftAmsIndex] = useState(0); const [rightAmsIndex, setRightAmsIndex] = useState(0); const [selectedTray, setSelectedTray] = useState(null); + // Track if load has been triggered (to disable Load button until unload or slot change) + const [loadTriggered, setLoadTriggered] = useState(false); + + // Modal states + const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null); + const [materialsModal, setMaterialsModal] = useState<{ tray: AMSTray; slotLabel: string; amsId: number } | null>(null); + + // Track user-initiated filament change operations (for showing progress card immediately) + const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean } | null>(null); + // Track the previous stage for detecting when operation completes + const prevStageRef = useRef(-1); + + // Track if we've done initial sync from tray_now + const initialSyncDone = useRef(false); + + // Sync selectedTray and loadTriggered from status.tray_now on initial load + // tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool + useEffect(() => { + if (initialSyncDone.current) return; + + const trayNow = status?.tray_now; + if (trayNow !== undefined && trayNow !== null) { + initialSyncDone.current = true; + if (trayNow !== 255 && trayNow !== 254) { + // Valid AMS tray is loaded - select it and set loadTriggered + console.log(`[AMSSectionDual] Initializing from tray_now: ${trayNow}`); + setSelectedTray(trayNow); + setLoadTriggered(true); + } else { + // No filament loaded or external spool + console.log(`[AMSSectionDual] tray_now=${trayNow} (no AMS filament loaded)`); + } + } + }, [status?.tray_now]); const loadMutation = useMutation({ - mutationFn: (trayId: number) => api.amsLoadFilament(printerId, trayId), + mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) => + api.amsLoadFilament(printerId, trayId, extruderId), + onSuccess: (data, { trayId, extruderId }) => { + console.log(`[AMSSectionDual] Load filament success (tray ${trayId}, extruder ${extruderId}):`, data); + // Disable Load button after successful load + setLoadTriggered(true); + }, + onError: (error, { trayId, extruderId }) => { + console.error(`[AMSSectionDual] Load filament error (tray ${trayId}, extruder ${extruderId}):`, error); + }, }); const unloadMutation = useMutation({ mutationFn: () => api.amsUnloadFilament(printerId), + onSuccess: (data) => { + console.log(`[AMSSectionDual] Unload filament success:`, data); + // Re-enable Load button after unload + setLoadTriggered(false); + }, + onError: (error) => { + console.error(`[AMSSectionDual] Unload filament error:`, error); + }, }); + // Handle tray selection - also re-enables Load button when changing slot + const handleTraySelect = (trayId: number | null) => { + if (trayId !== selectedTray) { + // Slot changed - re-enable Load button + setLoadTriggered(false); + } + setSelectedTray(trayId); + }; + + // Helper to get extruder ID for a given tray + const getExtruderIdForTray = (trayId: number): number | undefined => { + // For dual-nozzle printers, calculate which AMS unit the tray belongs to + // and look up which extruder it's connected to + if (!isDualNozzle) return undefined; + + // Find which AMS unit contains this tray + // Global tray ID format: amsId * 4 + slotIndex (for regular AMS) + // For AMS-HT (id >= 128): amsId * 4 + slotIndex (but only 2 slots) + for (const unit of amsUnits) { + const slotsInUnit = unit.id >= 128 ? 2 : 4; // AMS-HT has 2 slots + const baseSlotId = unit.id * 4; + if (trayId >= baseSlotId && trayId < baseSlotId + slotsInUnit) { + // Found the AMS unit - look up its extruder + const extruderId = amsExtruderMap[String(unit.id)]; + console.log(`[AMSSectionDual] Tray ${trayId} belongs to AMS ${unit.id}, extruder: ${extruderId}`); + return extruderId; + } + } + return undefined; + }; + const handleLoad = () => { + console.log(`[AMSSectionDual] handleLoad called, selectedTray: ${selectedTray}`); if (selectedTray !== null) { - loadMutation.mutate(selectedTray); + const extruderId = getExtruderIdForTray(selectedTray); + console.log(`[AMSSectionDual] Calling loadMutation.mutate(tray: ${selectedTray}, extruder: ${extruderId})`); + // Show filament change card immediately + setUserFilamentChange({ isLoading: true }); + loadMutation.mutate({ trayId: selectedTray, extruderId }); } }; const handleUnload = () => { + // Show filament change card immediately + setUserFilamentChange({ isLoading: false }); unloadMutation.mutate(); }; const isLoading = loadMutation.isPending || unloadMutation.isPending; + // Handlers for modals and actions + const handleHumidityClick = (humidity: number, temp: number) => { + setHumidityModal({ humidity, temp }); + }; + + const refreshMutation = useMutation({ + mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) => + api.refreshAmsTray(printerId, amsId, trayId), + onSuccess: (data, variables) => { + console.log(`[AMSSectionDual] Tray refresh success (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data); + }, + onError: (error, variables) => { + console.error(`[AMSSectionDual] Tray refresh error (AMS ${variables.amsId}, Tray ${variables.trayId}):`, error); + }, + }); + + const handleSlotRefresh = (amsId: number, slotId: number) => { + // Trigger RFID re-read for the specific tray + console.log(`[AMSSectionDual] Slot refresh triggered: AMS ${amsId}, Slot ${slotId}, printerId: ${printerId}`); + refreshMutation.mutate({ amsId, trayId: slotId }); + }; + + const handleEyeClick = (tray: AMSTray, slotLabel: string, amsId: number) => { + setMaterialsModal({ tray, slotLabel, amsId }); + }; + + // Determine if we're in a filament change stage (from MQTT) + const currentStage = status?.stg_cur ?? -1; + const isMqttFilamentChangeActive = [ + STAGE_HEATING_NOZZLE, + STAGE_FILAMENT_UNLOADING, + STAGE_FILAMENT_LOADING, + STAGE_CHANGING_FILAMENT, + ].includes(currentStage); + + // Auto-close card when operation completes + // Track when we transition from an active filament change stage back to -1 + useEffect(() => { + const wasInFilamentChange = [ + STAGE_HEATING_NOZZLE, + STAGE_FILAMENT_UNLOADING, + STAGE_FILAMENT_LOADING, + STAGE_CHANGING_FILAMENT, + ].includes(prevStageRef.current); + + if (isMqttFilamentChangeActive) { + // MQTT is now reporting a stage, clear user-triggered state + // Card will continue showing because isMqttFilamentChangeActive is true + setUserFilamentChange(null); + } else if (wasInFilamentChange && currentStage === -1) { + // Transition from active stage to idle - operation completed + // Close the card by clearing user state + setUserFilamentChange(null); + } + + // Update previous stage for next comparison + prevStageRef.current = currentStage; + }, [isMqttFilamentChangeActive, currentStage]); + + // Show FilamentChangeCard when either MQTT reports active stage OR user just clicked load/unload + const showFilamentChangeCard = isMqttFilamentChangeActive || userFilamentChange !== null; + + // Determine if loading or unloading for the card display + const isFilamentLoading = userFilamentChange !== null + ? userFilamentChange.isLoading + : (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_HEATING_NOZZLE); + + // Get the loaded tray info for wire coloring + // Wire coloring should show the path from the currently loaded filament to the extruder + // But ONLY if the currently displayed AMS panel is the one with the loaded filament + const trayNow = status?.tray_now ?? 255; + const getLoadedTrayInfo = (): { + leftActiveSlot: number | null; + rightActiveSlot: number | null; + leftFilamentColor: string | null; + rightFilamentColor: string | null; + } => { + // tray_now: 255 = no filament, 254 = external spool, 0-253 = valid tray ID + if (trayNow === 255 || trayNow === 254) { + return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null }; + } + + // Find which AMS and slot contains the loaded tray + for (const unit of amsUnits) { + const slotsInUnit = unit.id >= 128 ? 2 : 4; + const baseSlotId = unit.id * 4; + if (trayNow >= baseSlotId && trayNow < baseSlotId + slotsInUnit) { + const slotIndex = trayNow - baseSlotId; + const tray = unit.tray[slotIndex]; + const color = tray?.tray_color ?? null; + + // Determine if this AMS is on left or right side + const extruderId = amsExtruderMap[String(unit.id)]; + + // Check if this AMS unit is the one currently displayed in the panel + const currentLeftUnit = leftUnits[leftAmsIndex]; + const currentRightUnit = rightUnits[rightAmsIndex]; + + if (extruderId === 1) { + // Left side (extruder 1) + // Only show colored wiring if the currently displayed AMS unit is the one with loaded filament + const isDisplayed = currentLeftUnit?.id === unit.id; + return { + leftActiveSlot: isDisplayed ? slotIndex : null, + rightActiveSlot: null, + leftFilamentColor: isDisplayed ? color : null, // Hide color if different AMS is selected + rightFilamentColor: null + }; + } else { + // Right side (extruder 0) + const isDisplayed = currentRightUnit?.id === unit.id; + return { + leftActiveSlot: null, + rightActiveSlot: isDisplayed ? slotIndex : null, + leftFilamentColor: null, + rightFilamentColor: isDisplayed ? color : null // Hide color if different AMS is selected + }; + } + } + } + + return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null }; + }; + + const { leftActiveSlot, rightActiveSlot, leftFilamentColor, rightFilamentColor } = getLoadedTrayInfo(); + return (
{/* Dual Panel Layout - just the panels, no wiring */} @@ -316,7 +906,10 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua selectedAmsIndex={leftAmsIndex} onSelectAms={setLeftAmsIndex} selectedTray={selectedTray} - onSelectTray={setSelectedTray} + onSelectTray={handleTraySelect} + onHumidityClick={handleHumidityClick} + onSlotRefresh={handleSlotRefresh} + onEyeClick={handleEyeClick} /> {isDualNozzle && ( @@ -327,13 +920,26 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua selectedAmsIndex={rightAmsIndex} onSelectAms={setRightAmsIndex} selectedTray={selectedTray} - onSelectTray={setSelectedTray} + onSelectTray={handleTraySelect} + onHumidityClick={handleHumidityClick} + onSlotRefresh={handleSlotRefresh} + onEyeClick={handleEyeClick} /> )}
{/* Unified Wiring Layer - ALL wiring drawn here */} - + {/* Action Buttons Row - aligned with extruder */}
@@ -351,8 +957,12 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
); } diff --git a/frontend/src/pages/ControlPage.tsx b/frontend/src/pages/ControlPage.tsx index 3f8ee3b83..ac701c6df 100644 --- a/frontend/src/pages/ControlPage.tsx +++ b/frontend/src/pages/ControlPage.tsx @@ -255,6 +255,7 @@ export function ControlPage() { {/* AMS Section */}