AMSSectionDual.tsx 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030
  1. import { useState, useEffect, useRef } from 'react';
  2. import { useMutation } from '@tanstack/react-query';
  3. import { api } from '../../api/client';
  4. import type { PrinterStatus, AMSUnit, AMSTray } from '../../api/client';
  5. import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react';
  6. import { AMSHumidityModal } from './AMSHumidityModal';
  7. import { AMSMaterialsModal } from './AMSMaterialsModal';
  8. // Filament change stages from MQTT stg_cur
  9. const STAGE_HEATING_NOZZLE = 7;
  10. const STAGE_FILAMENT_UNLOADING = 22;
  11. const STAGE_FILAMENT_LOADING = 24;
  12. const STAGE_CHANGING_FILAMENT = 4;
  13. interface AMSSectionDualProps {
  14. printerId: number;
  15. printerModel: string;
  16. status: PrinterStatus | null | undefined;
  17. nozzleCount: number;
  18. }
  19. function hexToRgb(hex: string | null): string {
  20. if (!hex) return 'rgb(128, 128, 128)';
  21. const cleanHex = hex.replace('#', '').substring(0, 6);
  22. const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
  23. const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
  24. const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
  25. return `rgb(${r}, ${g}, ${b})`;
  26. }
  27. function isLightColor(hex: string | null): boolean {
  28. if (!hex) return false;
  29. const cleanHex = hex.replace('#', '').substring(0, 6);
  30. const r = parseInt(cleanHex.substring(0, 2), 16) || 0;
  31. const g = parseInt(cleanHex.substring(2, 4), 16) || 0;
  32. const b = parseInt(cleanHex.substring(4, 6), 16) || 0;
  33. const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  34. return luminance > 0.5;
  35. }
  36. // Single humidity icon that fills based on level
  37. // <25% = empty (dry/good)
  38. // <40% = half filled
  39. // >=40% = full (wet/bad)
  40. function HumidityIcon({ humidity }: { humidity: number }) {
  41. const getIconSrc = (): string => {
  42. if (humidity < 25) return '/icons/humidity-empty.svg';
  43. if (humidity < 40) return '/icons/humidity-half.svg';
  44. return '/icons/humidity-full.svg';
  45. };
  46. return (
  47. <img
  48. src={getIconSrc()}
  49. alt=""
  50. className="w-2.5 h-[14px]"
  51. />
  52. );
  53. }
  54. // Filament change progress card - appears during load/unload operations
  55. interface FilamentChangeCardProps {
  56. isLoading: boolean; // true = loading, false = unloading
  57. currentStage: number;
  58. onRetry?: () => void;
  59. }
  60. interface StepInfo {
  61. label: string;
  62. status: 'completed' | 'in_progress' | 'pending';
  63. stepNumber: number;
  64. }
  65. function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) {
  66. const [isCollapsed, setIsCollapsed] = useState(false);
  67. // Determine step status based on current stage
  68. // When stage is -1 (initial/waiting), show first step as in_progress
  69. const getLoadingSteps = (): StepInfo[] => {
  70. // Loading sequence: Heat nozzle (7) -> Push filament (24) -> Purge (still 24 or complete)
  71. let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
  72. let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
  73. let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending';
  74. if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
  75. // Initial state or heating - step 1 is active
  76. step1Status = 'in_progress';
  77. } else if (currentStage === STAGE_FILAMENT_LOADING) {
  78. // Loading filament - step 1 done, step 2 active
  79. step1Status = 'completed';
  80. step2Status = 'in_progress';
  81. }
  82. return [
  83. { label: 'Heat the nozzle', stepNumber: 1, status: step1Status },
  84. { label: 'Push new filament into extruder', stepNumber: 2, status: step2Status },
  85. { label: 'Purge old filament', stepNumber: 3, status: step3Status },
  86. ];
  87. };
  88. const getUnloadingSteps = (): StepInfo[] => {
  89. let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
  90. let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
  91. if (currentStage === -1 || currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
  92. // Initial state or heating - step 1 is active
  93. step1Status = 'in_progress';
  94. } else if (currentStage === STAGE_FILAMENT_UNLOADING) {
  95. // Unloading filament - step 1 done, step 2 active
  96. step1Status = 'completed';
  97. step2Status = 'in_progress';
  98. }
  99. return [
  100. { label: 'Heat the nozzle', stepNumber: 1, status: step1Status },
  101. { label: 'Retract filament from extruder', stepNumber: 2, status: step2Status },
  102. ];
  103. };
  104. const steps = isLoading ? getLoadingSteps() : getUnloadingSteps();
  105. const title = isLoading ? 'Loading' : 'Unloading';
  106. const headerText = isLoading ? 'Filament loading...' : 'Filament unloading...';
  107. return (
  108. <div className="mt-3 border-l-4 border-bambu-green bg-white dark:bg-bambu-dark-secondary rounded-r-lg overflow-hidden">
  109. {/* Collapsible header */}
  110. <button
  111. onClick={() => setIsCollapsed(!isCollapsed)}
  112. 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"
  113. >
  114. <span className="text-sm font-medium">{headerText}</span>
  115. {isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
  116. </button>
  117. {/* Content */}
  118. {!isCollapsed && (
  119. <div className="px-4 pb-4">
  120. <div className="flex gap-6">
  121. {/* Steps list */}
  122. <div className="flex-1">
  123. <h3 className="text-bambu-green font-semibold mb-3">{title}</h3>
  124. <div className="space-y-2">
  125. {steps.map((step) => (
  126. <div key={step.stepNumber} className="flex items-center gap-2">
  127. {/* Step indicator */}
  128. {step.status === 'completed' ? (
  129. <div className="w-5 h-5 rounded-full bg-bambu-green flex items-center justify-center flex-shrink-0">
  130. <svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  131. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
  132. </svg>
  133. </div>
  134. ) : step.status === 'in_progress' ? (
  135. <div className="w-5 h-5 rounded-full bg-bambu-green flex items-center justify-center flex-shrink-0">
  136. <span className="text-white text-xs font-bold">{step.stepNumber}</span>
  137. </div>
  138. ) : (
  139. <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">
  140. <span className="text-gray-400 dark:text-gray-500 text-xs font-medium">{step.stepNumber}</span>
  141. </div>
  142. )}
  143. {/* Step label */}
  144. <span className={`text-sm ${
  145. step.status === 'in_progress' ? 'text-gray-900 dark:text-white font-semibold' :
  146. step.status === 'completed' ? 'text-gray-500 dark:text-gray-400' : 'text-gray-400 dark:text-gray-500'
  147. }`}>
  148. {step.label}
  149. </span>
  150. </div>
  151. ))}
  152. </div>
  153. </div>
  154. {/* Extruder image */}
  155. <div className="flex-shrink-0">
  156. <img
  157. src="/icons/extruder-change-filament.png"
  158. alt="Extruder"
  159. className="w-[150px] h-auto"
  160. />
  161. </div>
  162. </div>
  163. {/* Retry button */}
  164. {onRetry && (
  165. <button
  166. onClick={onRetry}
  167. 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"
  168. >
  169. <RotateCw className="w-3.5 h-3.5" />
  170. Retry
  171. </button>
  172. )}
  173. </div>
  174. )}
  175. </div>
  176. );
  177. }
  178. interface AMSPanelContentProps {
  179. units: AMSUnit[];
  180. side: 'left' | 'right';
  181. isPrinting: boolean;
  182. selectedAmsIndex: number;
  183. onSelectAms: (index: number) => void;
  184. selectedTray: number | null;
  185. onSelectTray: (trayId: number | null) => void;
  186. onHumidityClick: (humidity: number, temp: number) => void;
  187. onSlotRefresh: (amsId: number, slotId: number) => void;
  188. onEyeClick: (tray: AMSTray, slotLabel: string, amsId: number) => void;
  189. }
  190. // Panel content - NO wiring, just slots and info
  191. // Get slot label based on AMS unit ID and tray index
  192. // Regular AMS (ID 0-3): A1, A2, A3, A4 / B1, B2, B3, B4 / etc.
  193. // AMS-HT (ID >= 128): HT-A, HT-B (for first HT unit), HT2-A, HT2-B (for second), etc.
  194. function getSlotLabel(amsId: number, trayIndex: number): string {
  195. if (amsId >= 128) {
  196. // AMS-HT unit - uses HT-A, HT-B naming
  197. const htUnitNumber = amsId - 128; // 0 for first HT, 1 for second, etc.
  198. const slotLetter = String.fromCharCode(65 + trayIndex); // A, B
  199. if (htUnitNumber === 0) {
  200. return `HT-${slotLetter}`;
  201. }
  202. return `HT${htUnitNumber + 1}-${slotLetter}`;
  203. }
  204. // Regular AMS - uses A1, B2, etc. naming
  205. const prefix = String.fromCharCode(65 + amsId); // 65 is ASCII for 'A'
  206. return `${prefix}${trayIndex + 1}`;
  207. }
  208. // Check if AMS unit is an AMS-HT (ID >= 128)
  209. function isAmsHT(amsId: number): boolean {
  210. return amsId >= 128;
  211. }
  212. function AMSPanelContent({
  213. units,
  214. side,
  215. isPrinting,
  216. selectedAmsIndex,
  217. onSelectAms,
  218. selectedTray,
  219. onSelectTray,
  220. onHumidityClick,
  221. onSlotRefresh,
  222. onEyeClick,
  223. }: AMSPanelContentProps) {
  224. const selectedUnit = units[selectedAmsIndex];
  225. const isHT = selectedUnit ? isAmsHT(selectedUnit.id) : false;
  226. return (
  227. <div className="flex-1 min-w-0">
  228. {/* AMS Tab Selectors */}
  229. <div className="flex gap-1.5 mb-2.5 p-1.5 bg-gray-300 dark:bg-bambu-dark rounded-lg">
  230. {units.map((unit, index) => (
  231. <button
  232. key={unit.id}
  233. onClick={() => onSelectAms(index)}
  234. className={`flex items-center p-1.5 rounded border-2 transition-colors ${
  235. selectedAmsIndex === index
  236. ? 'border-bambu-green bg-white dark:bg-bambu-dark-tertiary'
  237. : 'bg-gray-200 dark:bg-bambu-dark-secondary border-transparent hover:border-bambu-gray'
  238. }`}
  239. >
  240. <div className="flex gap-0.5">
  241. {unit.tray.map((tray) => (
  242. <div
  243. key={tray.id}
  244. className="w-2.5 h-2.5 rounded-full"
  245. style={{
  246. backgroundColor: tray.tray_color ? hexToRgb(tray.tray_color) : '#808080',
  247. }}
  248. />
  249. ))}
  250. </div>
  251. </button>
  252. ))}
  253. </div>
  254. {/* AMS Content */}
  255. {selectedUnit && (
  256. <div className="bg-gray-100 dark:bg-bambu-dark-secondary rounded-[10px] p-2.5">
  257. {/* AMS Header - Humidity & Temp - Centered - Clickable */}
  258. <button
  259. onClick={() => onHumidityClick(selectedUnit.humidity ?? 0, selectedUnit.temp ?? 0)}
  260. 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"
  261. >
  262. {selectedUnit.humidity !== null && (
  263. <span className="flex items-center gap-1.5">
  264. <HumidityIcon humidity={selectedUnit.humidity} />
  265. {selectedUnit.humidity} %
  266. </span>
  267. )}
  268. {selectedUnit.temp !== null && (
  269. <span className="flex items-center gap-1.5">
  270. <img src="/icons/temperature.svg" alt="" className="w-3.5 icon-theme" />
  271. {selectedUnit.temp}°C
  272. </span>
  273. )}
  274. </button>
  275. {/* Slot Labels */}
  276. <div className={`flex gap-2 mb-1.5 ${isHT ? 'justify-start pl-2' : 'justify-center'}`}>
  277. {selectedUnit.tray.map((tray, index) => {
  278. const slotLabel = getSlotLabel(selectedUnit.id, index);
  279. return (
  280. <button
  281. key={tray.id}
  282. onClick={() => onSlotRefresh(selectedUnit.id, tray.id)}
  283. 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"
  284. >
  285. {slotLabel}
  286. <img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
  287. </button>
  288. );
  289. })}
  290. </div>
  291. {/* AMS Slots - NO wiring here */}
  292. <div className={`flex gap-2 ${isHT ? 'justify-start pl-2' : 'justify-center'}`}>
  293. {selectedUnit.tray.map((tray, index) => {
  294. const globalTrayId = selectedUnit.id * 4 + tray.id;
  295. const isSelected = selectedTray === globalTrayId;
  296. const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE';
  297. const isLight = isLightColor(tray.tray_color);
  298. const slotLabel = getSlotLabel(selectedUnit.id, index);
  299. return (
  300. <div
  301. key={tray.id}
  302. onClick={() => {
  303. console.log(`[AMSSectionDual] Slot clicked: AMS ${selectedUnit.id}, tray ${tray.id}, globalTrayId: ${globalTrayId}, isEmpty: ${isEmpty}, isPrinting: ${isPrinting}, isSelected: ${isSelected}`);
  304. if (!isEmpty && !isPrinting) {
  305. onSelectTray(isSelected ? null : globalTrayId);
  306. }
  307. }}
  308. className={`w-14 h-[80px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark relative ${
  309. isSelected
  310. ? 'border-bambu-green'
  311. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  312. } ${isEmpty ? 'opacity-50' : 'cursor-pointer'}`}
  313. >
  314. {/* Fill level indicator - only for Bambu filaments with valid remain data */}
  315. {!isEmpty && tray.tray_uuid && tray.remain >= 0 && (
  316. <div
  317. className="absolute bottom-0 left-0 right-0 transition-all"
  318. style={{
  319. height: `${Math.min(100, Math.max(0, tray.remain))}%`,
  320. backgroundColor: hexToRgb(tray.tray_color),
  321. }}
  322. />
  323. )}
  324. {/* Full color background for non-Bambu filaments or no remain data */}
  325. {!isEmpty && (!tray.tray_uuid || tray.remain < 0) && (
  326. <div
  327. className="absolute inset-0"
  328. style={{
  329. backgroundColor: hexToRgb(tray.tray_color),
  330. }}
  331. />
  332. )}
  333. {/* Striped pattern for empty slots */}
  334. {isEmpty && (
  335. <div
  336. className="absolute inset-0"
  337. style={{
  338. background: 'repeating-linear-gradient(45deg, #3a3a3a, #3a3a3a 4px, #4a4a4a 4px, #4a4a4a 8px)',
  339. }}
  340. />
  341. )}
  342. {/* Content overlay */}
  343. <div className="relative w-full h-full flex flex-col items-center justify-end pb-[5px]">
  344. <span
  345. className={`text-[11px] font-semibold mb-1 ${
  346. isLight ? 'text-gray-800' : 'text-white'
  347. } ${isLight ? '' : 'drop-shadow-sm'}`}
  348. >
  349. {isEmpty ? '--' : tray.tray_type}
  350. </span>
  351. {!isEmpty && (
  352. <button
  353. onClick={(e) => {
  354. e.stopPropagation();
  355. onEyeClick(tray, slotLabel, selectedUnit.id);
  356. }}
  357. className={`w-4 h-4 flex items-center justify-center rounded hover:bg-black/20 transition-colors`}
  358. >
  359. <img
  360. src="/icons/eye.svg"
  361. alt="Settings"
  362. className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
  363. style={{ opacity: 0.8 }}
  364. />
  365. </button>
  366. )}
  367. </div>
  368. </div>
  369. );
  370. })}
  371. </div>
  372. </div>
  373. )}
  374. {/* No AMS message */}
  375. {units.length === 0 && (
  376. <div className="bg-bambu-dark-secondary rounded-[10px] p-6 text-center text-bambu-gray text-sm">
  377. No AMS connected to {side} nozzle
  378. </div>
  379. )}
  380. </div>
  381. );
  382. }
  383. // Unified wiring layer - draws ALL wiring in one place
  384. interface WiringLayerProps {
  385. isDualNozzle: boolean;
  386. leftSlotCount: number; // Number of slots on left panel (4 for regular AMS, 1-2 for AMS-HT)
  387. rightSlotCount: number; // Number of slots on right panel
  388. leftIsHT: boolean; // Is left panel an AMS-HT
  389. rightIsHT: boolean; // Is right panel an AMS-HT
  390. leftActiveSlot?: number | null; // Currently active slot index on left panel (0-3)
  391. rightActiveSlot?: number | null; // Currently active slot index on right panel (0-3)
  392. leftFilamentColor?: string | null; // Filament color for left active path
  393. rightFilamentColor?: string | null; // Filament color for right active path
  394. }
  395. function WiringLayer({
  396. isDualNozzle,
  397. leftSlotCount,
  398. rightSlotCount,
  399. leftIsHT,
  400. rightIsHT,
  401. leftActiveSlot,
  402. rightActiveSlot,
  403. leftFilamentColor,
  404. rightFilamentColor,
  405. }: WiringLayerProps) {
  406. if (!isDualNozzle) return null;
  407. // All measurements relative to this container
  408. // Container spans full width between panels
  409. // Regular AMS: slots → hub → down → toward center → down to extruder
  410. // AMS-HT: single slot on left → direct line down to extruder
  411. // Regular AMS: Slots are w-14 (56px) with gap-2 (8px), 4 slots = 248px total, centered in each ~300px panel
  412. // Left panel center ~150, slots start at 150 - 124 = 26
  413. // Slot centers: 26+28=54, 54+64=118, 118+64=182, 182+64=246
  414. // AMS-HT: Left aligned with pl-2 (8px), slot starts at 8px + 28px = 36px center
  415. // For 2 slots: 36, 100 (36 + 64)
  416. // Right panel calculations for regular AMS:
  417. // Right panel center ~450, slots start at 450 - 124 = 326
  418. // Slot centers: 326+28=354, 354+64=418, 418+64=482, 482+64=546
  419. // Right panel AMS-HT: Left aligned, starts at ~308 (300 panel offset + 8px padding)
  420. // Slot center: 308 + 28 = 336
  421. // Determine colors for wiring paths
  422. const defaultColor = '#909090';
  423. const leftActiveColor = leftFilamentColor ? hexToRgb(leftFilamentColor) : null;
  424. const rightActiveColor = rightFilamentColor ? hexToRgb(rightFilamentColor) : null;
  425. // Slot X positions for regular AMS (4 slots)
  426. const leftSlotX = [54, 118, 182, 246];
  427. // Right slot positions
  428. const rightSlotX = [354, 418, 482, 546];
  429. return (
  430. <div className="relative w-full pointer-events-none" style={{ height: '120px' }}>
  431. <svg
  432. className="absolute inset-0 w-full h-full"
  433. viewBox="0 0 600 120"
  434. preserveAspectRatio="xMidYMid meet"
  435. >
  436. {/* Left panel wiring */}
  437. {leftIsHT ? (
  438. <>
  439. {/* AMS-HT: Simple direct line from slot to extruder */}
  440. {/* Slot vertical lines - highlight active slot */}
  441. <line x1="36" y1="0" x2="36" y2="36" stroke={leftActiveSlot === 0 && leftActiveColor ? leftActiveColor : defaultColor} strokeWidth={leftActiveSlot === 0 && leftActiveColor ? 3 : 2} />
  442. {leftSlotCount > 1 && (
  443. <line x1="100" y1="0" x2="100" y2="36" stroke={leftActiveSlot === 1 && leftActiveColor ? leftActiveColor : defaultColor} strokeWidth={leftActiveSlot === 1 && leftActiveColor ? 3 : 2} />
  444. )}
  445. {leftSlotCount > 1 && (
  446. <line x1="36" y1="36" x2="100" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
  447. )}
  448. {/* Path to extruder - always colored if filament loaded */}
  449. <line x1={leftSlotCount > 1 ? "68" : "36"} y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
  450. <line x1="288" y1="36" x2="288" y2="85" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
  451. </>
  452. ) : (
  453. <>
  454. {/* Regular AMS: 4 slots with hub */}
  455. {/* Vertical lines from 4 slots - highlight active slot */}
  456. {leftSlotX.map((x, i) => (
  457. <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} />
  458. ))}
  459. {/* Horizontal bar connecting left slots - highlight from active slot to hub */}
  460. {leftActiveSlot !== null && leftActiveSlot !== undefined && leftActiveColor ? (
  461. <>
  462. {/* Background bar */}
  463. <line x1="54" y1="14" x2="246" y2="14" stroke={defaultColor} strokeWidth="2" />
  464. {/* Highlight segment from active slot to hub (center at 150) */}
  465. <line
  466. x1={Math.min(leftSlotX[leftActiveSlot], 150)}
  467. y1="14"
  468. x2={Math.max(leftSlotX[leftActiveSlot], 150)}
  469. y2="14"
  470. stroke={leftActiveColor}
  471. strokeWidth="3"
  472. />
  473. </>
  474. ) : (
  475. <line x1="54" y1="14" x2="246" y2="14" stroke={defaultColor} strokeWidth="2" />
  476. )}
  477. {/* Left hub */}
  478. <rect x="136" y="8" width="28" height="14" rx="2" fill={leftActiveColor ?? '#c0c0c0'} stroke={leftActiveColor ?? defaultColor} strokeWidth="1" />
  479. {/* Vertical from left hub down */}
  480. <line x1="150" y1="22" x2="150" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
  481. {/* Horizontal from left hub toward center */}
  482. <line x1="150" y1="36" x2="288" y2="36" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
  483. {/* Vertical down to left extruder inlet */}
  484. <line x1="288" y1="36" x2="288" y2="85" stroke={leftActiveColor ?? defaultColor} strokeWidth={leftActiveColor ? 3 : 2} />
  485. </>
  486. )}
  487. {/* Right panel wiring */}
  488. {rightIsHT ? (
  489. <>
  490. {/* AMS-HT: Simple direct line from slot to extruder */}
  491. <line x1="336" y1="0" x2="336" y2="36" stroke={rightActiveSlot === 0 && rightActiveColor ? rightActiveColor : defaultColor} strokeWidth={rightActiveSlot === 0 && rightActiveColor ? 3 : 2} />
  492. {rightSlotCount > 1 && (
  493. <line x1="400" y1="0" x2="400" y2="36" stroke={rightActiveSlot === 1 && rightActiveColor ? rightActiveColor : defaultColor} strokeWidth={rightActiveSlot === 1 && rightActiveColor ? 3 : 2} />
  494. )}
  495. {rightSlotCount > 1 && (
  496. <line x1="336" y1="36" x2="400" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
  497. )}
  498. <line x1="312" y1="36" x2={rightSlotCount > 1 ? "368" : "336"} y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
  499. <line x1="312" y1="36" x2="312" y2="85" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
  500. </>
  501. ) : (
  502. <>
  503. {/* Regular AMS: 4 slots with hub */}
  504. {/* Vertical lines from 4 slots - highlight active slot */}
  505. {rightSlotX.map((x, i) => (
  506. <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} />
  507. ))}
  508. {/* Horizontal bar connecting right slots - highlight from active slot to hub */}
  509. {rightActiveSlot !== null && rightActiveSlot !== undefined && rightActiveColor ? (
  510. <>
  511. {/* Background bar */}
  512. <line x1="354" y1="14" x2="546" y2="14" stroke={defaultColor} strokeWidth="2" />
  513. {/* Highlight segment from active slot to hub (center at 450) */}
  514. <line
  515. x1={Math.min(rightSlotX[rightActiveSlot], 450)}
  516. y1="14"
  517. x2={Math.max(rightSlotX[rightActiveSlot], 450)}
  518. y2="14"
  519. stroke={rightActiveColor}
  520. strokeWidth="3"
  521. />
  522. </>
  523. ) : (
  524. <line x1="354" y1="14" x2="546" y2="14" stroke={defaultColor} strokeWidth="2" />
  525. )}
  526. {/* Right hub */}
  527. <rect x="436" y="8" width="28" height="14" rx="2" fill={rightActiveColor ?? '#c0c0c0'} stroke={rightActiveColor ?? defaultColor} strokeWidth="1" />
  528. {/* Vertical from right hub down */}
  529. <line x1="450" y1="22" x2="450" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
  530. {/* Horizontal from right hub toward center */}
  531. <line x1="312" y1="36" x2="450" y2="36" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
  532. {/* Vertical down to right extruder inlet */}
  533. <line x1="312" y1="36" x2="312" y2="85" stroke={rightActiveColor ?? defaultColor} strokeWidth={rightActiveColor ? 3 : 2} />
  534. </>
  535. )}
  536. </svg>
  537. {/* Extruder image container - positioned at bottom center */}
  538. {/* Image is 56x71 pixels, scaled to h=50px = width ~39px */}
  539. {/* Scale factor: 50/71 = 0.704 */}
  540. {/* Green circles in original image: left center ~(15.2,34.2), right center ~(41.0,33.9) */}
  541. {/* Scaled positions: left x≈10.7, right x≈28.9, y≈24 from top */}
  542. <div className="absolute left-1/2 -translate-x-1/2 bottom-0 h-[50px] w-[39px]">
  543. <img
  544. src="/icons/extruder-left-right.png"
  545. alt="Extruder"
  546. className="h-full w-full"
  547. />
  548. {/* Extruder inlet indicator circles - overlay on extruder image */}
  549. {/* Left inlet (extruder 1) - left side of extruder */}
  550. <div
  551. className="absolute w-[8px] h-[8px] rounded-full"
  552. style={{
  553. left: '7px',
  554. top: '20px',
  555. backgroundColor: leftActiveColor ?? 'transparent',
  556. }}
  557. />
  558. {/* Right inlet (extruder 0) - right side of extruder */}
  559. <div
  560. className="absolute w-[8px] h-[8px] rounded-full"
  561. style={{
  562. left: '25px',
  563. top: '20px',
  564. backgroundColor: rightActiveColor ?? 'transparent',
  565. }}
  566. />
  567. </div>
  568. </div>
  569. );
  570. }
  571. export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }: AMSSectionDualProps) {
  572. const isConnected = status?.connected ?? false;
  573. const isPrinting = status?.state === 'RUNNING';
  574. const isDualNozzle = nozzleCount > 1;
  575. const amsUnits: AMSUnit[] = status?.ams ?? [];
  576. // Per-AMS extruder map: {ams_id: extruder_id} where extruder 0=right, 1=left
  577. // This is extracted from each AMS unit's info field bit 8 in the backend
  578. // Note: JSON keys are always strings, so we use Record<string, number>
  579. const amsExtruderMap: Record<string, number> = status?.ams_extruder_map ?? {};
  580. // Distribute AMS units based on ams_extruder_map
  581. // Each AMS unit's info field tells us which extruder it's connected to:
  582. // extruder 0 = right nozzle, extruder 1 = left nozzle
  583. const leftUnits = (() => {
  584. if (!isDualNozzle) return amsUnits;
  585. if (Object.keys(amsExtruderMap).length > 0) {
  586. // Filter AMS units assigned to extruder 1 (left nozzle)
  587. // JSON keys are strings, so convert unit.id to string
  588. return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
  589. }
  590. // Fallback: odd indices go to left (extruder 1)
  591. return amsUnits.filter((_, i) => i % 2 === 1);
  592. })();
  593. const rightUnits = (() => {
  594. if (!isDualNozzle) return [];
  595. if (Object.keys(amsExtruderMap).length > 0) {
  596. // Filter AMS units assigned to extruder 0 (right nozzle)
  597. // JSON keys are strings, so convert unit.id to string
  598. return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
  599. }
  600. // Fallback: even indices go to right (extruder 0)
  601. return amsUnits.filter((_, i) => i % 2 === 0);
  602. })();
  603. const [leftAmsIndex, setLeftAmsIndex] = useState(0);
  604. const [rightAmsIndex, setRightAmsIndex] = useState(0);
  605. const [selectedTray, setSelectedTray] = useState<number | null>(null);
  606. // Track if load has been triggered (to disable Load button until unload or slot change)
  607. const [loadTriggered, setLoadTriggered] = useState(false);
  608. // Modal states
  609. const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null);
  610. const [materialsModal, setMaterialsModal] = useState<{ tray: AMSTray; slotLabel: string; amsId: number } | null>(null);
  611. // Track user-initiated filament change operations (for showing progress card immediately)
  612. const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean } | null>(null);
  613. // Track the previous stage for detecting when operation completes
  614. const prevStageRef = useRef<number>(-1);
  615. // Track if we've done initial sync from tray_now
  616. const initialSyncDone = useRef(false);
  617. // Sync selectedTray and loadTriggered from status.tray_now on initial load
  618. // tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool
  619. useEffect(() => {
  620. if (initialSyncDone.current) return;
  621. const trayNow = status?.tray_now;
  622. if (trayNow !== undefined && trayNow !== null) {
  623. initialSyncDone.current = true;
  624. if (trayNow !== 255 && trayNow !== 254) {
  625. // Valid AMS tray is loaded - select it and set loadTriggered
  626. console.log(`[AMSSectionDual] Initializing from tray_now: ${trayNow}`);
  627. setSelectedTray(trayNow);
  628. setLoadTriggered(true);
  629. } else {
  630. // No filament loaded or external spool
  631. console.log(`[AMSSectionDual] tray_now=${trayNow} (no AMS filament loaded)`);
  632. }
  633. }
  634. }, [status?.tray_now]);
  635. const loadMutation = useMutation({
  636. mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) =>
  637. api.amsLoadFilament(printerId, trayId, extruderId),
  638. onSuccess: (data, { trayId, extruderId }) => {
  639. console.log(`[AMSSectionDual] Load filament success (tray ${trayId}, extruder ${extruderId}):`, data);
  640. // Disable Load button after successful load
  641. setLoadTriggered(true);
  642. },
  643. onError: (error, { trayId, extruderId }) => {
  644. console.error(`[AMSSectionDual] Load filament error (tray ${trayId}, extruder ${extruderId}):`, error);
  645. },
  646. });
  647. const unloadMutation = useMutation({
  648. mutationFn: () => api.amsUnloadFilament(printerId),
  649. onSuccess: (data) => {
  650. console.log(`[AMSSectionDual] Unload filament success:`, data);
  651. // Re-enable Load button after unload
  652. setLoadTriggered(false);
  653. },
  654. onError: (error) => {
  655. console.error(`[AMSSectionDual] Unload filament error:`, error);
  656. },
  657. });
  658. // Handle tray selection - also re-enables Load button when changing slot
  659. const handleTraySelect = (trayId: number | null) => {
  660. if (trayId !== selectedTray) {
  661. // Slot changed - re-enable Load button
  662. setLoadTriggered(false);
  663. }
  664. setSelectedTray(trayId);
  665. };
  666. // Helper to get extruder ID for a given tray
  667. const getExtruderIdForTray = (trayId: number): number | undefined => {
  668. // For dual-nozzle printers, calculate which AMS unit the tray belongs to
  669. // and look up which extruder it's connected to
  670. if (!isDualNozzle) return undefined;
  671. // Find which AMS unit contains this tray
  672. // Global tray ID format: amsId * 4 + slotIndex (for regular AMS)
  673. // For AMS-HT (id >= 128): amsId * 4 + slotIndex (but only 2 slots)
  674. for (const unit of amsUnits) {
  675. const slotsInUnit = unit.id >= 128 ? 2 : 4; // AMS-HT has 2 slots
  676. const baseSlotId = unit.id * 4;
  677. if (trayId >= baseSlotId && trayId < baseSlotId + slotsInUnit) {
  678. // Found the AMS unit - look up its extruder
  679. const extruderId = amsExtruderMap[String(unit.id)];
  680. console.log(`[AMSSectionDual] Tray ${trayId} belongs to AMS ${unit.id}, extruder: ${extruderId}`);
  681. return extruderId;
  682. }
  683. }
  684. return undefined;
  685. };
  686. const handleLoad = () => {
  687. console.log(`[AMSSectionDual] handleLoad called, selectedTray: ${selectedTray}`);
  688. if (selectedTray !== null) {
  689. const extruderId = getExtruderIdForTray(selectedTray);
  690. console.log(`[AMSSectionDual] Calling loadMutation.mutate(tray: ${selectedTray}, extruder: ${extruderId})`);
  691. // Show filament change card immediately
  692. setUserFilamentChange({ isLoading: true });
  693. loadMutation.mutate({ trayId: selectedTray, extruderId });
  694. }
  695. };
  696. const handleUnload = () => {
  697. // Show filament change card immediately
  698. setUserFilamentChange({ isLoading: false });
  699. unloadMutation.mutate();
  700. };
  701. const isLoading = loadMutation.isPending || unloadMutation.isPending;
  702. // Handlers for modals and actions
  703. const handleHumidityClick = (humidity: number, temp: number) => {
  704. setHumidityModal({ humidity, temp });
  705. };
  706. const refreshMutation = useMutation({
  707. mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) =>
  708. api.refreshAmsTray(printerId, amsId, trayId),
  709. onSuccess: (data, variables) => {
  710. console.log(`[AMSSectionDual] Tray refresh success (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data);
  711. },
  712. onError: (error, variables) => {
  713. console.error(`[AMSSectionDual] Tray refresh error (AMS ${variables.amsId}, Tray ${variables.trayId}):`, error);
  714. },
  715. });
  716. const handleSlotRefresh = (amsId: number, slotId: number) => {
  717. // Trigger RFID re-read for the specific tray
  718. console.log(`[AMSSectionDual] Slot refresh triggered: AMS ${amsId}, Slot ${slotId}, printerId: ${printerId}`);
  719. refreshMutation.mutate({ amsId, trayId: slotId });
  720. };
  721. const handleEyeClick = (tray: AMSTray, slotLabel: string, amsId: number) => {
  722. setMaterialsModal({ tray, slotLabel, amsId });
  723. };
  724. // Determine if we're in a filament change stage (from MQTT)
  725. const currentStage = status?.stg_cur ?? -1;
  726. const isMqttFilamentChangeActive = [
  727. STAGE_HEATING_NOZZLE,
  728. STAGE_FILAMENT_UNLOADING,
  729. STAGE_FILAMENT_LOADING,
  730. STAGE_CHANGING_FILAMENT,
  731. ].includes(currentStage);
  732. // Auto-close card when operation completes
  733. // Track when we transition from an active filament change stage back to -1
  734. useEffect(() => {
  735. const wasInFilamentChange = [
  736. STAGE_HEATING_NOZZLE,
  737. STAGE_FILAMENT_UNLOADING,
  738. STAGE_FILAMENT_LOADING,
  739. STAGE_CHANGING_FILAMENT,
  740. ].includes(prevStageRef.current);
  741. if (isMqttFilamentChangeActive) {
  742. // MQTT is now reporting a stage, clear user-triggered state
  743. // Card will continue showing because isMqttFilamentChangeActive is true
  744. setUserFilamentChange(null);
  745. } else if (wasInFilamentChange && currentStage === -1) {
  746. // Transition from active stage to idle - operation completed
  747. // Close the card by clearing user state
  748. setUserFilamentChange(null);
  749. }
  750. // Update previous stage for next comparison
  751. prevStageRef.current = currentStage;
  752. }, [isMqttFilamentChangeActive, currentStage]);
  753. // Show FilamentChangeCard when either MQTT reports active stage OR user just clicked load/unload
  754. const showFilamentChangeCard = isMqttFilamentChangeActive || userFilamentChange !== null;
  755. // Determine if loading or unloading for the card display
  756. const isFilamentLoading = userFilamentChange !== null
  757. ? userFilamentChange.isLoading
  758. : (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_HEATING_NOZZLE);
  759. // Get the loaded tray info for wire coloring
  760. // Wire coloring should show the path from the currently loaded filament to the extruder
  761. // But ONLY if the currently displayed AMS panel is the one with the loaded filament
  762. const trayNow = status?.tray_now ?? 255;
  763. const getLoadedTrayInfo = (): {
  764. leftActiveSlot: number | null;
  765. rightActiveSlot: number | null;
  766. leftFilamentColor: string | null;
  767. rightFilamentColor: string | null;
  768. } => {
  769. // tray_now: 255 = no filament, 254 = external spool, 0-253 = valid tray ID
  770. if (trayNow === 255 || trayNow === 254) {
  771. return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null };
  772. }
  773. // Find which AMS and slot contains the loaded tray
  774. for (const unit of amsUnits) {
  775. const slotsInUnit = unit.id >= 128 ? 2 : 4;
  776. const baseSlotId = unit.id * 4;
  777. if (trayNow >= baseSlotId && trayNow < baseSlotId + slotsInUnit) {
  778. const slotIndex = trayNow - baseSlotId;
  779. const tray = unit.tray[slotIndex];
  780. const color = tray?.tray_color ?? null;
  781. // Determine if this AMS is on left or right side
  782. const extruderId = amsExtruderMap[String(unit.id)];
  783. // Check if this AMS unit is the one currently displayed in the panel
  784. const currentLeftUnit = leftUnits[leftAmsIndex];
  785. const currentRightUnit = rightUnits[rightAmsIndex];
  786. if (extruderId === 1) {
  787. // Left side (extruder 1)
  788. // Only show colored wiring if the currently displayed AMS unit is the one with loaded filament
  789. const isDisplayed = currentLeftUnit?.id === unit.id;
  790. return {
  791. leftActiveSlot: isDisplayed ? slotIndex : null,
  792. rightActiveSlot: null,
  793. leftFilamentColor: isDisplayed ? color : null, // Hide color if different AMS is selected
  794. rightFilamentColor: null
  795. };
  796. } else {
  797. // Right side (extruder 0)
  798. const isDisplayed = currentRightUnit?.id === unit.id;
  799. return {
  800. leftActiveSlot: null,
  801. rightActiveSlot: isDisplayed ? slotIndex : null,
  802. leftFilamentColor: null,
  803. rightFilamentColor: isDisplayed ? color : null // Hide color if different AMS is selected
  804. };
  805. }
  806. }
  807. }
  808. return { leftActiveSlot: null, rightActiveSlot: null, leftFilamentColor: null, rightFilamentColor: null };
  809. };
  810. const { leftActiveSlot, rightActiveSlot, leftFilamentColor, rightFilamentColor } = getLoadedTrayInfo();
  811. return (
  812. <div className="bg-bambu-dark-tertiary rounded-[10px] p-3">
  813. {/* Dual Panel Layout - just the panels, no wiring */}
  814. <div className="flex gap-5">
  815. <AMSPanelContent
  816. units={leftUnits}
  817. side="left"
  818. isPrinting={isPrinting}
  819. selectedAmsIndex={leftAmsIndex}
  820. onSelectAms={setLeftAmsIndex}
  821. selectedTray={selectedTray}
  822. onSelectTray={handleTraySelect}
  823. onHumidityClick={handleHumidityClick}
  824. onSlotRefresh={handleSlotRefresh}
  825. onEyeClick={handleEyeClick}
  826. />
  827. {isDualNozzle && (
  828. <AMSPanelContent
  829. units={rightUnits}
  830. side="right"
  831. isPrinting={isPrinting}
  832. selectedAmsIndex={rightAmsIndex}
  833. onSelectAms={setRightAmsIndex}
  834. selectedTray={selectedTray}
  835. onSelectTray={handleTraySelect}
  836. onHumidityClick={handleHumidityClick}
  837. onSlotRefresh={handleSlotRefresh}
  838. onEyeClick={handleEyeClick}
  839. />
  840. )}
  841. </div>
  842. {/* Unified Wiring Layer - ALL wiring drawn here */}
  843. <WiringLayer
  844. isDualNozzle={isDualNozzle}
  845. leftSlotCount={leftUnits[leftAmsIndex]?.tray?.length ?? 4}
  846. rightSlotCount={rightUnits[rightAmsIndex]?.tray?.length ?? 4}
  847. leftIsHT={leftUnits[leftAmsIndex] ? isAmsHT(leftUnits[leftAmsIndex].id) : false}
  848. rightIsHT={rightUnits[rightAmsIndex] ? isAmsHT(rightUnits[rightAmsIndex].id) : false}
  849. leftActiveSlot={leftActiveSlot}
  850. rightActiveSlot={rightActiveSlot}
  851. leftFilamentColor={leftFilamentColor}
  852. rightFilamentColor={rightFilamentColor}
  853. />
  854. {/* Action Buttons Row - aligned with extruder */}
  855. <div className="flex items-start -mt-[50px]">
  856. <div className="flex items-center gap-2">
  857. <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">
  858. <img src="/icons/ams-settings.svg" alt="Settings" className="w-5 icon-theme" />
  859. </button>
  860. <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">
  861. Auto-refill
  862. </button>
  863. </div>
  864. <div className="flex-1" />
  865. <div className="flex items-center gap-2">
  866. <button
  867. onClick={handleUnload}
  868. disabled={!isConnected || isPrinting || isLoading || !loadTriggered}
  869. className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
  870. !isConnected || isPrinting || isLoading || !loadTriggered
  871. ? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
  872. : 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
  873. }`}
  874. >
  875. {unloadMutation.isPending ? (
  876. <Loader2 className="w-4 h-4 animate-spin" />
  877. ) : (
  878. 'Unload'
  879. )}
  880. </button>
  881. <button
  882. onClick={handleLoad}
  883. disabled={!isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered}
  884. className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
  885. !isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered
  886. ? 'bg-white dark:bg-bambu-dark text-gray-400 dark:text-gray-500 border-gray-200 dark:border-bambu-dark-tertiary cursor-not-allowed'
  887. : 'bg-bambu-green text-white border-bambu-green hover:bg-bambu-green-dark hover:border-bambu-green-dark'
  888. }`}
  889. >
  890. {loadMutation.isPending ? (
  891. <Loader2 className="w-4 h-4 animate-spin" />
  892. ) : (
  893. 'Load'
  894. )}
  895. </button>
  896. </div>
  897. </div>
  898. {/* Error messages */}
  899. {(loadMutation.error || unloadMutation.error) && (
  900. <p className="mt-2 text-sm text-red-500 text-center">
  901. {(loadMutation.error || unloadMutation.error)?.message}
  902. </p>
  903. )}
  904. {/* Filament Change Progress Card - appears during load/unload operations */}
  905. {showFilamentChangeCard && (
  906. <FilamentChangeCard
  907. isLoading={isFilamentLoading}
  908. currentStage={currentStage}
  909. />
  910. )}
  911. {/* Humidity Modal */}
  912. {humidityModal && (
  913. <AMSHumidityModal
  914. humidity={humidityModal.humidity}
  915. temperature={humidityModal.temp}
  916. dryingStatus="idle"
  917. onClose={() => setHumidityModal(null)}
  918. />
  919. )}
  920. {/* Materials Settings Modal */}
  921. {materialsModal && (
  922. <AMSMaterialsModal
  923. tray={materialsModal.tray}
  924. amsId={materialsModal.amsId}
  925. slotLabel={materialsModal.slotLabel}
  926. printerId={printerId}
  927. printerModel={printerModel}
  928. nozzleDiameter={status?.nozzles?.[0]?.nozzle_diameter || '0.4'}
  929. onClose={() => setMaterialsModal(null)}
  930. />
  931. )}
  932. </div>
  933. );
  934. }