| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030 |
- import { useState, useEffect, useRef } from 'react';
- import { useMutation } from '@tanstack/react-query';
- import { api } from '../../api/client';
- 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;
- }
- 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 isLightColor(hex: string | null): boolean {
- if (!hex) return false;
- const cleanHex = hex.replace('#', '').substring(0, 6);
- const r = parseInt(cleanHex.substring(0, 2), 16) || 0;
- const g = parseInt(cleanHex.substring(2, 4), 16) || 0;
- const b = parseInt(cleanHex.substring(4, 6), 16) || 0;
- const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
- return luminance > 0.5;
- }
- // Single humidity icon that fills based on level
- // <25% = empty (dry/good)
- // <40% = half filled
- // >=40% = full (wet/bad)
- function HumidityIcon({ 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-2.5 h-[14px]"
- />
- );
- }
- // 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 (
- <div className="mt-3 border-l-4 border-bambu-green bg-white dark:bg-bambu-dark-secondary rounded-r-lg overflow-hidden">
- {/* Collapsible header */}
- <button
- onClick={() => setIsCollapsed(!isCollapsed)}
- className="w-full flex items-center justify-between px-4 py-2 text-bambu-green hover:bg-gray-50 dark:hover:bg-bambu-dark-tertiary transition-colors"
- >
- <span className="text-sm font-medium">{headerText}</span>
- {isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
- </button>
- {/* Content */}
- {!isCollapsed && (
- <div className="px-4 pb-4">
- <div className="flex gap-6">
- {/* Steps list */}
- <div className="flex-1">
- <h3 className="text-bambu-green font-semibold mb-3">{title}</h3>
- <div className="space-y-2">
- {steps.map((step) => (
- <div key={step.stepNumber} className="flex items-center gap-2">
- {/* Step indicator */}
- {step.status === 'completed' ? (
- <div className="w-5 h-5 rounded-full bg-bambu-green flex items-center justify-center flex-shrink-0">
- <svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
- </svg>
- </div>
- ) : step.status === 'in_progress' ? (
- <div className="w-5 h-5 rounded-full bg-bambu-green flex items-center justify-center flex-shrink-0">
- <span className="text-white text-xs font-bold">{step.stepNumber}</span>
- </div>
- ) : (
- <div className="w-5 h-5 rounded-full border-2 border-gray-400 dark:border-gray-500 flex items-center justify-center flex-shrink-0">
- <span className="text-gray-400 dark:text-gray-500 text-xs font-medium">{step.stepNumber}</span>
- </div>
- )}
- {/* Step label */}
- <span className={`text-sm ${
- step.status === 'in_progress' ? 'text-gray-900 dark:text-white font-semibold' :
- step.status === 'completed' ? 'text-gray-500 dark:text-gray-400' : 'text-gray-400 dark:text-gray-500'
- }`}>
- {step.label}
- </span>
- </div>
- ))}
- </div>
- </div>
- {/* Extruder image */}
- <div className="flex-shrink-0">
- <img
- src="/icons/extruder-change-filament.png"
- alt="Extruder"
- className="w-[150px] h-auto"
- />
- </div>
- </div>
- {/* Retry button */}
- {onRetry && (
- <button
- onClick={onRetry}
- className="mt-3 px-4 py-1.5 border border-bambu-gray rounded-full text-sm text-bambu-gray hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-1.5"
- >
- <RotateCw className="w-3.5 h-3.5" />
- Retry
- </button>
- )}
- </div>
- )}
- </div>
- );
- }
- interface AMSPanelContentProps {
- units: AMSUnit[];
- side: 'left' | 'right';
- isPrinting: boolean;
- selectedAmsIndex: number;
- 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,
- isPrinting,
- selectedAmsIndex,
- onSelectAms,
- selectedTray,
- onSelectTray,
- onHumidityClick,
- onSlotRefresh,
- onEyeClick,
- }: AMSPanelContentProps) {
- const selectedUnit = units[selectedAmsIndex];
- const isHT = selectedUnit ? isAmsHT(selectedUnit.id) : false;
- return (
- <div className="flex-1 min-w-0">
- {/* AMS Tab Selectors */}
- <div className="flex gap-1.5 mb-2.5 p-1.5 bg-gray-300 dark:bg-bambu-dark rounded-lg">
- {units.map((unit, index) => (
- <button
- key={unit.id}
- onClick={() => onSelectAms(index)}
- className={`flex items-center p-1.5 rounded border-2 transition-colors ${
- selectedAmsIndex === index
- ? 'border-bambu-green bg-white dark:bg-bambu-dark-tertiary'
- : 'bg-gray-200 dark:bg-bambu-dark-secondary border-transparent hover:border-bambu-gray'
- }`}
- >
- <div className="flex gap-0.5">
- {unit.tray.map((tray) => (
- <div
- key={tray.id}
- className="w-2.5 h-2.5 rounded-full"
- style={{
- backgroundColor: tray.tray_color ? hexToRgb(tray.tray_color) : '#808080',
- }}
- />
- ))}
- </div>
- </button>
- ))}
- </div>
- {/* AMS Content */}
- {selectedUnit && (
- <div className="bg-gray-100 dark:bg-bambu-dark-secondary rounded-[10px] p-2.5">
- {/* AMS Header - Humidity & Temp - Centered - Clickable */}
- <button
- onClick={() => onHumidityClick(selectedUnit.humidity ?? 0, selectedUnit.temp ?? 0)}
- className="flex items-center justify-center gap-4 text-xs text-bambu-gray mb-2.5 w-full py-1 hover:bg-gray-50 dark:hover:bg-bambu-dark-tertiary rounded-md transition-colors cursor-pointer"
- >
- {selectedUnit.humidity !== null && (
- <span className="flex items-center gap-1.5">
- <HumidityIcon humidity={selectedUnit.humidity} />
- {selectedUnit.humidity} %
- </span>
- )}
- {selectedUnit.temp !== null && (
- <span className="flex items-center gap-1.5">
- <img src="/icons/temperature.svg" alt="" className="w-3.5 icon-theme" />
- {selectedUnit.temp}°C
- </span>
- )}
- </button>
- {/* Slot Labels */}
- <div className={`flex gap-2 mb-1.5 ${isHT ? 'justify-start pl-2' : 'justify-center'}`}>
- {selectedUnit.tray.map((tray, index) => {
- const slotLabel = getSlotLabel(selectedUnit.id, index);
- return (
- <button
- key={tray.id}
- onClick={() => onSlotRefresh(selectedUnit.id, tray.id)}
- className="w-14 flex items-center justify-center gap-0.5 text-[10px] text-bambu-gray px-1.5 py-[3px] bg-bambu-dark rounded-full border border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary transition-colors"
- >
- {slotLabel}
- <img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
- </button>
- );
- })}
- </div>
- {/* AMS Slots - NO wiring here */}
- <div className={`flex gap-2 ${isHT ? 'justify-start pl-2' : 'justify-center'}`}>
- {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 (
- <div
- key={tray.id}
- onClick={() => {
- console.log(`[AMSSectionDual] Slot clicked: AMS ${selectedUnit.id}, tray ${tray.id}, globalTrayId: ${globalTrayId}, isEmpty: ${isEmpty}, isPrinting: ${isPrinting}, isSelected: ${isSelected}`);
- if (!isEmpty && !isPrinting) {
- onSelectTray(isSelected ? null : globalTrayId);
- }
- }}
- className={`w-14 h-[80px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark relative ${
- isSelected
- ? 'border-bambu-green'
- : 'border-bambu-dark-tertiary hover:border-bambu-gray'
- } ${isEmpty ? 'opacity-50' : 'cursor-pointer'}`}
- >
- {/* Fill level indicator - only for Bambu filaments with valid remain data */}
- {!isEmpty && tray.tray_uuid && tray.remain >= 0 && (
- <div
- className="absolute bottom-0 left-0 right-0 transition-all"
- style={{
- height: `${Math.min(100, Math.max(0, tray.remain))}%`,
- backgroundColor: hexToRgb(tray.tray_color),
- }}
- />
- )}
- {/* Full color background for non-Bambu filaments or no remain data */}
- {!isEmpty && (!tray.tray_uuid || tray.remain < 0) && (
- <div
- className="absolute inset-0"
- style={{
- backgroundColor: hexToRgb(tray.tray_color),
- }}
- />
- )}
- {/* Striped pattern for empty slots */}
- {isEmpty && (
- <div
- className="absolute inset-0"
- style={{
- background: 'repeating-linear-gradient(45deg, #3a3a3a, #3a3a3a 4px, #4a4a4a 4px, #4a4a4a 8px)',
- }}
- />
- )}
- {/* Content overlay */}
- <div className="relative w-full h-full flex flex-col items-center justify-end pb-[5px]">
- <span
- className={`text-[11px] font-semibold mb-1 ${
- isLight ? 'text-gray-800' : 'text-white'
- } ${isLight ? '' : 'drop-shadow-sm'}`}
- >
- {isEmpty ? '--' : tray.tray_type}
- </span>
- {!isEmpty && (
- <button
- onClick={(e) => {
- e.stopPropagation();
- onEyeClick(tray, slotLabel, selectedUnit.id);
- }}
- className={`w-4 h-4 flex items-center justify-center rounded hover:bg-black/20 transition-colors`}
- >
- <img
- src="/icons/eye.svg"
- alt="Settings"
- className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
- style={{ opacity: 0.8 }}
- />
- </button>
- )}
- </div>
- </div>
- );
- })}
- </div>
- </div>
- )}
- {/* No AMS message */}
- {units.length === 0 && (
- <div className="bg-bambu-dark-secondary rounded-[10px] p-6 text-center text-bambu-gray text-sm">
- No AMS connected to {side} nozzle
- </div>
- )}
- </div>
- );
- }
- // 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,
- 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
- // 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 (
- <div className="relative w-full pointer-events-none" style={{ height: '120px' }}>
- <svg
- className="absolute inset-0 w-full h-full"
- viewBox="0 0 600 120"
- preserveAspectRatio="xMidYMid meet"
- >
- {/* Left panel wiring */}
- {leftIsHT ? (
- <>
- {/* AMS-HT: Simple direct line from slot to extruder */}
- {/* Slot vertical lines - highlight active slot */}
- <line x1="36" y1="0" x2="36" y2="36" stroke={leftActiveSlot === 0 && leftActiveColor ? leftActiveColor : defaultColor} strokeWidth={leftActiveSlot === 0 && leftActiveColor ? 3 : 2} />
- {leftSlotCount > 1 && (
- <line x1="100" y1="0" x2="100" y2="36" stroke={leftActiveSlot === 1 && leftActiveColor ? leftActiveColor : defaultColor} strokeWidth={leftActiveSlot === 1 && leftActiveColor ? 3 : 2} />
- )}
- {leftSlotCount > 1 && (
- <line x1="36" y1="36" x2="100" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
- )}
- {/* Path to extruder - always colored if filament loaded */}
- <line x1={leftSlotCount > 1 ? "68" : "36"} y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
- <line x1="288" y1="36" x2="288" y2="85" 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) => (
- <line key={`left-slot-${i}`} x1={x} y1="0" x2={x} y2="14" stroke={leftActiveSlot === i && leftActiveColor ? leftActiveColor : defaultColor} strokeWidth={leftActiveSlot === i && leftActiveColor ? 3 : 2} />
- ))}
- {/* Horizontal bar connecting left slots - highlight from active slot to hub */}
- {leftActiveSlot !== null && leftActiveSlot !== undefined && leftActiveColor ? (
- <>
- {/* Background bar */}
- <line x1="54" y1="14" x2="246" y2="14" stroke={defaultColor} strokeWidth="2" />
- {/* Highlight segment from active slot to hub (center at 150) */}
- <line
- x1={Math.min(leftSlotX[leftActiveSlot], 150)}
- y1="14"
- x2={Math.max(leftSlotX[leftActiveSlot], 150)}
- y2="14"
- stroke={leftActiveColor}
- strokeWidth="3"
- />
- </>
- ) : (
- <line x1="54" y1="14" x2="246" y2="14" stroke={defaultColor} strokeWidth="2" />
- )}
- {/* Left hub */}
- <rect x="136" y="8" width="28" height="14" rx="2" fill={leftActiveColor ?? '#c0c0c0'} stroke={leftActiveColor ?? defaultColor} strokeWidth="1" />
- {/* Vertical from left hub down */}
- <line x1="150" y1="22" x2="150" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
- {/* Horizontal from left hub toward center */}
- <line x1="150" y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
- {/* Vertical down to left extruder inlet */}
- <line x1="288" y1="36" x2="288" y2="85" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
- </>
- )}
- {/* Right panel wiring */}
- {rightIsHT ? (
- <>
- {/* AMS-HT: Simple direct line from slot to extruder */}
- <line x1="336" y1="0" x2="336" y2="36" stroke={rightActiveSlot === 0 && rightActiveColor ? rightActiveColor : defaultColor} strokeWidth={rightActiveSlot === 0 && rightActiveColor ? 3 : 2} />
- {rightSlotCount > 1 && (
- <line x1="400" y1="0" x2="400" y2="36" stroke={rightActiveSlot === 1 && rightActiveColor ? rightActiveColor : defaultColor} strokeWidth={rightActiveSlot === 1 && rightActiveColor ? 3 : 2} />
- )}
- {rightSlotCount > 1 && (
- <line x1="336" y1="36" x2="400" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
- )}
- <line x1="312" y1="36" x2={rightSlotCount > 1 ? "368" : "336"} y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
- <line x1="312" y1="36" x2="312" y2="85" 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) => (
- <line key={`right-slot-${i}`} x1={x} y1="0" x2={x} y2="14" stroke={rightActiveSlot === i && rightActiveColor ? rightActiveColor : defaultColor} strokeWidth={rightActiveSlot === i && rightActiveColor ? 3 : 2} />
- ))}
- {/* Horizontal bar connecting right slots - highlight from active slot to hub */}
- {rightActiveSlot !== null && rightActiveSlot !== undefined && rightActiveColor ? (
- <>
- {/* Background bar */}
- <line x1="354" y1="14" x2="546" y2="14" stroke={defaultColor} strokeWidth="2" />
- {/* Highlight segment from active slot to hub (center at 450) */}
- <line
- x1={Math.min(rightSlotX[rightActiveSlot], 450)}
- y1="14"
- x2={Math.max(rightSlotX[rightActiveSlot], 450)}
- y2="14"
- stroke={rightActiveColor}
- strokeWidth="3"
- />
- </>
- ) : (
- <line x1="354" y1="14" x2="546" y2="14" stroke={defaultColor} strokeWidth="2" />
- )}
- {/* Right hub */}
- <rect x="436" y="8" width="28" height="14" rx="2" fill={rightActiveColor ?? '#c0c0c0'} stroke={rightActiveColor ?? defaultColor} strokeWidth="1" />
- {/* Vertical from right hub down */}
- <line x1="450" y1="22" x2="450" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
- {/* Horizontal from right hub toward center */}
- <line x1="312" y1="36" x2="450" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
- {/* Vertical down to right extruder inlet */}
- <line x1="312" y1="36" x2="312" y2="85" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
- </>
- )}
- </svg>
- {/* 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 */}
- <div className="absolute left-1/2 -translate-x-1/2 bottom-0 h-[50px] w-[39px]">
- <img
- src="/icons/extruder-left-right.png"
- alt="Extruder"
- className="h-full w-full"
- />
- {/* Extruder inlet indicator circles - overlay on extruder image */}
- {/* Left inlet (extruder 1) - left side of extruder */}
- <div
- className="absolute w-[8px] h-[8px] rounded-full"
- style={{
- left: '7px',
- top: '20px',
- backgroundColor: leftActiveColor ?? 'transparent',
- }}
- />
- {/* Right inlet (extruder 0) - right side of extruder */}
- <div
- className="absolute w-[8px] h-[8px] rounded-full"
- style={{
- left: '25px',
- top: '20px',
- backgroundColor: rightActiveColor ?? 'transparent',
- }}
- />
- </div>
- </div>
- );
- }
- 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<string, number>
- const amsExtruderMap: Record<string, number> = status?.ams_extruder_map ?? {};
- // 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<number | null>(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<number>(-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, 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) {
- 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 (
- <div className="bg-bambu-dark-tertiary rounded-[10px] p-3">
- {/* Dual Panel Layout - just the panels, no wiring */}
- <div className="flex gap-5">
- <AMSPanelContent
- units={leftUnits}
- side="left"
- isPrinting={isPrinting}
- selectedAmsIndex={leftAmsIndex}
- onSelectAms={setLeftAmsIndex}
- selectedTray={selectedTray}
- onSelectTray={handleTraySelect}
- onHumidityClick={handleHumidityClick}
- onSlotRefresh={handleSlotRefresh}
- onEyeClick={handleEyeClick}
- />
- {isDualNozzle && (
- <AMSPanelContent
- units={rightUnits}
- side="right"
- isPrinting={isPrinting}
- selectedAmsIndex={rightAmsIndex}
- onSelectAms={setRightAmsIndex}
- selectedTray={selectedTray}
- onSelectTray={handleTraySelect}
- onHumidityClick={handleHumidityClick}
- onSlotRefresh={handleSlotRefresh}
- onEyeClick={handleEyeClick}
- />
- )}
- </div>
- {/* Unified Wiring Layer - ALL wiring drawn here */}
- <WiringLayer
- isDualNozzle={isDualNozzle}
- leftSlotCount={leftUnits[leftAmsIndex]?.tray?.length ?? 4}
- rightSlotCount={rightUnits[rightAmsIndex]?.tray?.length ?? 4}
- leftIsHT={leftUnits[leftAmsIndex] ? isAmsHT(leftUnits[leftAmsIndex].id) : false}
- rightIsHT={rightUnits[rightAmsIndex] ? isAmsHT(rightUnits[rightAmsIndex].id) : false}
- leftActiveSlot={leftActiveSlot}
- rightActiveSlot={rightActiveSlot}
- leftFilamentColor={leftFilamentColor}
- rightFilamentColor={rightFilamentColor}
- />
- {/* Action Buttons Row - aligned with extruder */}
- <div className="flex items-start -mt-[50px]">
- <div className="flex items-center gap-2">
- <button className="w-10 h-10 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark border border-bambu-dark-tertiary flex items-center justify-center">
- <img src="/icons/ams-settings.svg" alt="Settings" className="w-5 icon-theme" />
- </button>
- <button className="px-[18px] py-2.5 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark border border-bambu-dark-tertiary text-sm text-bambu-gray flex items-center gap-1.5">
- Auto-refill
- </button>
- </div>
- <div className="flex-1" />
- <div className="flex items-center gap-2">
- <button
- onClick={handleUnload}
- disabled={!isConnected || isPrinting || isLoading || !loadTriggered}
- className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
- !isConnected || isPrinting || isLoading || !loadTriggered
- ? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
- : 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
- }`}
- >
- {unloadMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- 'Unload'
- )}
- </button>
- <button
- onClick={handleLoad}
- disabled={!isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered}
- className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
- !isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered
- ? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
- : 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
- }`}
- >
- {loadMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- 'Load'
- )}
- </button>
- </div>
- </div>
- {/* Error messages */}
- {(loadMutation.error || unloadMutation.error) && (
- <p className="mt-2 text-sm text-red-500 text-center">
- {(loadMutation.error || unloadMutation.error)?.message}
- </p>
- )}
- {/* Filament Change Progress Card - appears during load/unload operations */}
- {showFilamentChangeCard && (
- <FilamentChangeCard
- isLoading={isFilamentLoading}
- currentStage={currentStage}
- />
- )}
- {/* Humidity Modal */}
- {humidityModal && (
- <AMSHumidityModal
- humidity={humidityModal.humidity}
- temperature={humidityModal.temp}
- dryingStatus="idle"
- onClose={() => setHumidityModal(null)}
- />
- )}
- {/* Materials Settings Modal */}
- {materialsModal && (
- <AMSMaterialsModal
- tray={materialsModal.tray}
- amsId={materialsModal.amsId}
- slotLabel={materialsModal.slotLabel}
- printerId={printerId}
- printerModel={printerModel}
- nozzleDiameter={status?.nozzles?.[0]?.nozzle_diameter || '0.4'}
- onClose={() => setMaterialsModal(null)}
- />
- )}
- </div>
- );
- }
|