|
|
@@ -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 (
|
|
|
+ <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';
|
|
|
@@ -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 (
|
|
|
<div className="flex-1 min-w-0">
|
|
|
{/* AMS Tab Selectors */}
|
|
|
- <div className="flex gap-1.5 mb-2.5 p-1.5 bg-white dark:bg-bambu-dark rounded-lg">
|
|
|
+ <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-bambu-dark-secondary'
|
|
|
- : 'bg-bambu-dark border-transparent hover:border-bambu-gray'
|
|
|
+ ? '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">
|
|
|
@@ -103,9 +279,12 @@ function AMSPanelContent({
|
|
|
|
|
|
{/* AMS Content */}
|
|
|
{selectedUnit && (
|
|
|
- <div className="bg-white dark:bg-bambu-dark-secondary rounded-[10px] p-2.5">
|
|
|
- {/* AMS Header - Humidity & Temp - Centered */}
|
|
|
- <div className="flex items-center justify-center gap-4 text-xs text-bambu-gray mb-2.5">
|
|
|
+ <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} />
|
|
|
@@ -118,46 +297,79 @@ function AMSPanelContent({
|
|
|
{selectedUnit.temp}°C
|
|
|
</span>
|
|
|
)}
|
|
|
- </div>
|
|
|
+ </button>
|
|
|
|
|
|
{/* Slot Labels */}
|
|
|
- <div className="flex justify-center gap-2 mb-1.5">
|
|
|
- {selectedUnit.tray.map((tray, index) => (
|
|
|
- <div
|
|
|
- key={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"
|
|
|
- >
|
|
|
- {slotPrefix}{index + 1}
|
|
|
- <img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
|
|
|
- </div>
|
|
|
- ))}
|
|
|
+ <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 justify-center gap-2">
|
|
|
- {selectedUnit.tray.map((tray) => {
|
|
|
+ <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 (
|
|
|
- <button
|
|
|
+ <div
|
|
|
key={tray.id}
|
|
|
- onClick={() => !isEmpty && onSelectTray(isSelected ? null : globalTrayId)}
|
|
|
- disabled={isEmpty || isPrinting}
|
|
|
- className={`w-14 h-[80px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark ${
|
|
|
+ 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-[#d4a84b]'
|
|
|
+ ? 'border-bambu-green'
|
|
|
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
|
|
- } ${isEmpty ? 'opacity-50' : ''} disabled:cursor-not-allowed`}
|
|
|
+ } ${isEmpty ? 'opacity-50' : 'cursor-pointer'}`}
|
|
|
>
|
|
|
- <div
|
|
|
- className="w-full h-full flex flex-col items-center justify-end pb-[5px]"
|
|
|
- style={{
|
|
|
- backgroundColor: isEmpty ? undefined : hexToRgb(tray.tray_color),
|
|
|
- }}
|
|
|
- >
|
|
|
+ {/* 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'
|
|
|
@@ -166,15 +378,23 @@ function AMSPanelContent({
|
|
|
{isEmpty ? '--' : tray.tray_type}
|
|
|
</span>
|
|
|
{!isEmpty && (
|
|
|
- <img
|
|
|
- src="/icons/eye.svg"
|
|
|
- alt=""
|
|
|
- className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
|
|
|
- style={{ opacity: 0.8 }}
|
|
|
- />
|
|
|
+ <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>
|
|
|
- </button>
|
|
|
+ </div>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
@@ -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 (
|
|
|
- <div className="relative w-full" style={{ height: '120px' }}>
|
|
|
- {/* 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 */}
|
|
|
+ <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 */}
|
|
|
- {/* Vertical lines from 4 slots */}
|
|
|
- <line x1="54" y1="0" x2="54" y2="14" stroke="#909090" strokeWidth="2" />
|
|
|
- <line x1="118" y1="0" x2="118" y2="14" stroke="#909090" strokeWidth="2" />
|
|
|
- <line x1="182" y1="0" x2="182" y2="14" stroke="#909090" strokeWidth="2" />
|
|
|
- <line x1="246" y1="0" x2="246" y2="14" stroke="#909090" strokeWidth="2" />
|
|
|
+ {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 */}
|
|
|
- <line x1="54" y1="14" x2="246" y2="14" stroke="#909090" strokeWidth="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="#c0c0c0" stroke="#909090" strokeWidth="1" />
|
|
|
+ {/* 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="#909090" strokeWidth="2" />
|
|
|
+ {/* 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="#909090" strokeWidth="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="#909090" strokeWidth="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 */}
|
|
|
- {/* Vertical lines from 4 slots */}
|
|
|
- <line x1="354" y1="0" x2="354" y2="14" stroke="#909090" strokeWidth="2" />
|
|
|
- <line x1="418" y1="0" x2="418" y2="14" stroke="#909090" strokeWidth="2" />
|
|
|
- <line x1="482" y1="0" x2="482" y2="14" stroke="#909090" strokeWidth="2" />
|
|
|
- <line x1="546" y1="0" x2="546" y2="14" stroke="#909090" strokeWidth="2" />
|
|
|
+ {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 */}
|
|
|
- <line x1="354" y1="14" x2="546" y2="14" stroke="#909090" strokeWidth="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="#c0c0c0" stroke="#909090" strokeWidth="1" />
|
|
|
+ {/* 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="#909090" strokeWidth="2" />
|
|
|
+ {/* 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="#909090" strokeWidth="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="#909090" strokeWidth="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 - positioned at bottom center */}
|
|
|
- <img
|
|
|
- src="/icons/extruder-left-right.png"
|
|
|
- alt="Extruder"
|
|
|
- className="absolute left-1/2 -translate-x-1/2 bottom-0 h-[50px]"
|
|
|
- />
|
|
|
+ {/* 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, 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<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 leftUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 0) : amsUnits;
|
|
|
- const rightUnits = isDualNozzle ? 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: 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 (
|
|
|
<div className="bg-bambu-dark-tertiary rounded-[10px] p-3">
|
|
|
{/* 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}
|
|
|
/>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
{/* Unified Wiring Layer - ALL wiring drawn here */}
|
|
|
- <WiringLayer isDualNozzle={isDualNozzle} />
|
|
|
+ <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]">
|
|
|
@@ -351,8 +957,12 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
|
|
|
<div className="flex items-center gap-2">
|
|
|
<button
|
|
|
onClick={handleUnload}
|
|
|
- disabled={!isConnected || isPrinting || isLoading}
|
|
|
- className="px-7 py-2.5 rounded-lg bg-bambu-dark hover:bg-bambu-dark-secondary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ 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" />
|
|
|
@@ -362,8 +972,12 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
|
|
|
</button>
|
|
|
<button
|
|
|
onClick={handleLoad}
|
|
|
- disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
|
|
|
- className="px-7 py-2.5 rounded-lg bg-bambu-dark hover:bg-bambu-dark-secondary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ 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" />
|
|
|
@@ -380,6 +994,37 @@ export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDua
|
|
|
{(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>
|
|
|
);
|
|
|
}
|