Wiring up AMS module

This commit is contained in:
Martin Ziegler
2025-12-04 17:59:54 +01:00
parent b8d32c04a7
commit 6a6ce3cc22
18 changed files with 959 additions and 469 deletions
@@ -57,9 +57,12 @@ export interface MaterialSettings {
function hexToRgb(hex: string | null): string {
if (!hex) return 'rgb(128, 128, 128)';
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
const rParsed = parseInt(cleanHex.substring(0, 2), 16);
const gParsed = parseInt(cleanHex.substring(2, 4), 16);
const bParsed = parseInt(cleanHex.substring(4, 6), 16);
const r = isNaN(rParsed) ? 128 : rParsed;
const g = isNaN(gParsed) ? 128 : gParsed;
const b = isNaN(bParsed) ? 128 : bParsed;
return `rgb(${r}, ${g}, ${b})`;
}
+6 -3
View File
@@ -13,9 +13,12 @@ function hexToRgb(hex: string | null): string {
if (!hex) return 'rgb(128, 128, 128)';
// Handle RRGGBBAA format
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
const rParsed = parseInt(cleanHex.substring(0, 2), 16);
const gParsed = parseInt(cleanHex.substring(2, 4), 16);
const bParsed = parseInt(cleanHex.substring(4, 6), 16);
const r = isNaN(rParsed) ? 128 : rParsed;
const g = isNaN(gParsed) ? 128 : gParsed;
const b = isNaN(bParsed) ? 128 : bParsed;
return `rgb(${r}, ${g}, ${b})`;
}
@@ -22,20 +22,29 @@ interface AMSSectionDualProps {
function hexToRgb(hex: string | null): string {
if (!hex) return 'rgb(128, 128, 128)';
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
const rParsed = parseInt(cleanHex.substring(0, 2), 16);
const gParsed = parseInt(cleanHex.substring(2, 4), 16);
const bParsed = parseInt(cleanHex.substring(4, 6), 16);
const r = isNaN(rParsed) ? 128 : rParsed;
const g = isNaN(gParsed) ? 128 : gParsed;
const b = isNaN(bParsed) ? 128 : bParsed;
return `rgb(${r}, ${g}, ${b})`;
}
function isLightColor(hex: string | null): boolean {
if (!hex) return false;
const cleanHex = hex.replace('#', '').substring(0, 6);
const r = parseInt(cleanHex.substring(0, 2), 16) || 0;
const g = parseInt(cleanHex.substring(2, 4), 16) || 0;
const b = parseInt(cleanHex.substring(4, 6), 16) || 0;
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5;
// Ensure we have a valid 6-char hex
if (cleanHex.length < 6) return false;
const rParsed = parseInt(cleanHex.substring(0, 2), 16);
const gParsed = parseInt(cleanHex.substring(2, 4), 16);
const bParsed = parseInt(cleanHex.substring(4, 6), 16);
// If any parsing fails, treat as dark
if (isNaN(rParsed) || isNaN(gParsed) || isNaN(bParsed)) return false;
// Use relative luminance formula (WCAG)
const luminance = (0.299 * rParsed + 0.587 * gParsed + 0.114 * bParsed) / 255;
// Lower threshold (0.45) to ensure more colors get white text for better contrast
return luminance > 0.45;
}
// Single humidity icon that fills based on level
@@ -73,27 +82,74 @@ interface StepInfo {
function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
// Track the highest progress step reached to show proper progression
// 0 = initial, 1 = push started, 2 = heating, 3 = purging
const [progressStep, setProgressStep] = useState(0);
const prevStageRef = useRef(currentStage);
// Determine step status based on current stage
// When stage is -1 (initial/waiting), show first step as in_progress
// Update progress based on stage transitions
useEffect(() => {
// For loading: track progression through stages
if (isLoading) {
if (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_CHANGING_FILAMENT) {
// Push/loading stage - at least step 1
if (progressStep < 1) setProgressStep(1);
} else if (currentStage === STAGE_HEATING_NOZZLE) {
// Heating stage - at least step 2
if (progressStep < 2) setProgressStep(2);
} else if (progressStep >= 2 && currentStage === STAGE_CHANGING_FILAMENT) {
// After heating, back to changing = purge stage
setProgressStep(3);
}
} else {
// For unloading: track heating -> unloading
if (currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
if (progressStep < 1) setProgressStep(1);
} else if (currentStage === STAGE_FILAMENT_UNLOADING) {
if (progressStep < 2) setProgressStep(2);
}
}
// Reset progress when stage returns to idle
if (currentStage === -1 && prevStageRef.current !== -1) {
// Operation completed, reset for next time
setProgressStep(0);
}
prevStageRef.current = currentStage;
}, [currentStage, isLoading, progressStep]);
// Initialize progress to step 1 when card first shows
useEffect(() => {
if (progressStep === 0) {
setProgressStep(1);
}
}, []);
// Determine step status based on tracked progress
const getLoadingSteps = (): StepInfo[] => {
// Loading sequence: Heat nozzle (7) -> Push filament (24) -> Purge (still 24 or complete)
// Loading sequence: Push filament -> Heat nozzle -> Purge
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
if (progressStep >= 3) {
// Purging - steps 1 & 2 done, step 3 active
step1Status = 'completed';
step2Status = 'completed';
step3Status = 'in_progress';
} else if (progressStep >= 2) {
// Heating - step 1 done, step 2 active
step1Status = 'completed';
step2Status = 'in_progress';
} else if (progressStep >= 1) {
// Pushing - step 1 active
step1Status = 'in_progress';
}
return [
{ label: 'Heat the nozzle', stepNumber: 1, status: step1Status },
{ label: 'Push new filament into extruder', stepNumber: 2, status: step2Status },
{ label: 'Push new filament into extruder', stepNumber: 1, status: step1Status },
{ label: 'Heat the nozzle', stepNumber: 2, status: step2Status },
{ label: 'Purge old filament', stepNumber: 3, status: step3Status },
];
};
@@ -102,13 +158,13 @@ function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChange
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
if (progressStep >= 2) {
// Unloading - step 1 done, step 2 active
step1Status = 'completed';
step2Status = 'in_progress';
} else if (progressStep >= 1) {
// Heating - step 1 active
step1Status = 'in_progress';
}
return [
@@ -340,8 +396,8 @@ function AMSPanelContent({
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
} ${isEmpty ? 'opacity-50' : 'cursor-pointer'}`}
>
{/* Fill level indicator - only for Bambu filaments with valid remain data */}
{!isEmpty && tray.tray_uuid && tray.remain >= 0 && (
{/* Fill level indicator - show for any filament with valid remain data */}
{!isEmpty && tray.remain >= 0 && (
<div
className="absolute bottom-0 left-0 right-0 transition-all"
style={{
@@ -350,8 +406,8 @@ function AMSPanelContent({
}}
/>
)}
{/* Full color background for non-Bambu filaments or no remain data */}
{!isEmpty && (!tray.tray_uuid || tray.remain < 0) && (
{/* Full color background only when no valid remain data */}
{!isEmpty && tray.remain < 0 && (
<div
className="absolute inset-0"
style={{
@@ -371,9 +427,8 @@ function AMSPanelContent({
{/* Content overlay */}
<div className="relative w-full h-full flex flex-col items-center justify-end pb-[5px]">
<span
className={`text-[11px] font-semibold mb-1 ${
isLight ? 'text-gray-800' : 'text-white'
} ${isLight ? '' : 'drop-shadow-sm'}`}
className="text-[11px] font-semibold mb-1"
style={{ color: isLight ? '#000000' : '#ffffff' }}
>
{isEmpty ? '--' : tray.tray_type}
</span>
@@ -633,34 +688,32 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
// 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
// In Bambu slicer convention: extruder 0 = left nozzle, extruder 1 = right nozzle
const leftUnits = (() => {
if (!isDualNozzle) return amsUnits;
if (Object.keys(amsExtruderMap).length > 0) {
// Filter AMS units assigned to extruder 1 (left nozzle)
// Filter AMS units assigned to extruder 0 (left nozzle in slicer convention)
// JSON keys are strings, so convert unit.id to string
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
}
// Fallback: odd indices go to left (extruder 1)
return amsUnits.filter((_, i) => i % 2 === 1);
// Fallback: even indices go to left (extruder 0)
return amsUnits.filter((_, i) => i % 2 === 0);
})();
const rightUnits = (() => {
if (!isDualNozzle) return [];
if (Object.keys(amsExtruderMap).length > 0) {
// Filter AMS units assigned to extruder 0 (right nozzle)
// Filter AMS units assigned to extruder 1 (right nozzle in slicer convention)
// JSON keys are strings, so convert unit.id to string
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
}
// Fallback: even indices go to right (extruder 0)
return amsUnits.filter((_, i) => i % 2 === 0);
// Fallback: odd indices go to right (extruder 1)
return amsUnits.filter((_, i) => i % 2 === 1);
})();
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);
@@ -674,7 +727,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
// 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
// Sync selectedTray 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;
@@ -683,10 +736,10 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
if (trayNow !== undefined && trayNow !== null) {
initialSyncDone.current = true;
if (trayNow !== 255 && trayNow !== 254) {
// Valid AMS tray is loaded - select it and set loadTriggered
// Valid AMS tray is loaded - select it
// Note: We don't set loadTriggered here because the user may want to load a different slot
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)`);
@@ -699,8 +752,6 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
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);
@@ -711,20 +762,14 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
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
// Handle tray selection
const handleTraySelect = (trayId: number | null) => {
if (trayId !== selectedTray) {
// Slot changed - re-enable Load button
setLoadTriggered(false);
}
setSelectedTray(trayId);
};
@@ -805,7 +850,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
].includes(currentStage);
// Auto-close card when operation completes
// Track when we transition from an active filament change stage back to -1
// Track when we transition from an active filament change stage back to idle
useEffect(() => {
const wasInFilamentChange = [
STAGE_HEATING_NOZZLE,
@@ -818,8 +863,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
// 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
} else if (wasInFilamentChange && !isMqttFilamentChangeActive) {
// Transition from active stage to idle (any non-filament-change stage, not just -1)
// Close the card by clearing user state
setUserFilamentChange(null);
}
@@ -867,8 +912,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
const currentLeftUnit = leftUnits[leftAmsIndex];
const currentRightUnit = rightUnits[rightAmsIndex];
if (extruderId === 1) {
// Left side (extruder 1)
if (extruderId === 0) {
// Left side (extruder 0) - leftUnits filters for amsExtruderMap === 0
// Only show colored wiring if the currently displayed AMS unit is the one with loaded filament
const isDisplayed = currentLeftUnit?.id === unit.id;
return {
@@ -878,7 +923,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
rightFilamentColor: null
};
} else {
// Right side (extruder 0)
// Right side (extruder 1) - rightUnits filters for amsExtruderMap === 1
const isDisplayed = currentRightUnit?.id === unit.id;
return {
leftActiveSlot: null,
@@ -957,9 +1002,9 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
<div className="flex items-center gap-2">
<button
onClick={handleUnload}
disabled={!isConnected || isPrinting || isLoading || !loadTriggered}
disabled={!isConnected || isPrinting || isLoading || trayNow === 255}
className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
!isConnected || isPrinting || isLoading || !loadTriggered
!isConnected || isPrinting || isLoading || trayNow === 255
? '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'
}`}
@@ -972,9 +1017,9 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
</button>
<button
onClick={handleLoad}
disabled={!isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered}
disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
className={`px-7 py-2.5 rounded-lg text-sm transition-colors border ${
!isConnected || isPrinting || selectedTray === null || isLoading || loadTriggered
!isConnected || isPrinting || selectedTray === null || isLoading
? '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'
}`}