- Added basic printer controls

- Reprint/Stop
  - Pause/Resume
  - AMS slot re-read
This commit is contained in:
maziggy
2026-01-01 15:52:43 +01:00
parent 2c61ba930a
commit f7994df84f
2 changed files with 411 additions and 56 deletions
+21
View File
@@ -1275,6 +1275,27 @@ export const api = {
method: 'POST',
}),
// Print Control
stopPrint: (printerId: number) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/print/stop`, {
method: 'POST',
}),
pausePrint: (printerId: number) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/print/pause`, {
method: 'POST',
}),
resumePrint: (printerId: number) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
method: 'POST',
}),
// AMS Control
refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
request<{ success: boolean; message: string }>(
`/printers/${printerId}/ams/${amsId}/slot/${slotId}/refresh`,
{ method: 'POST' }
),
// MQTT Debug Logging
enableMQTTLogging: (printerId: number) =>
request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {
+390 -56
View File
@@ -28,6 +28,9 @@ import {
Video,
Search,
Loader2,
Square,
Pause,
Play,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api, discoveryApi } from '../api/client';
@@ -41,6 +44,7 @@ import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal
import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
import { AMSHistoryModal } from '../components/AMSHistoryModal';
import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
import { useToast } from '../contexts/ToastContext';
// Bambu Lab color code mapping (color suffix from tray_id_name -> color name)
// tray_id_name format: "A00-Y2" where Y2 is the color code
@@ -663,6 +667,7 @@ function PrinterCard({
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { showToast } = useToast();
const [showMenu, setShowMenu] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteArchives, setDeleteArchives] = useState(true);
@@ -672,6 +677,9 @@ function PrinterCard({
const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
const [showHMSModal, setShowHMSModal] = useState(false);
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showPauseConfirm, setShowPauseConfirm] = useState(false);
const [showResumeConfirm, setShowResumeConfirm] = useState(false);
const [amsHistoryModal, setAmsHistoryModal] = useState<{
amsId: number;
amsLabel: string;
@@ -828,6 +836,127 @@ function PrinterCard({
},
});
// Print control mutations
const stopPrintMutation = useMutation({
mutationFn: () => api.stopPrint(printer.id),
onSuccess: () => {
showToast('Print stopped');
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
},
onError: (error: Error) => showToast(error.message || 'Failed to stop print', 'error'),
});
const pausePrintMutation = useMutation({
mutationFn: () => api.pausePrint(printer.id),
onSuccess: () => {
showToast('Print paused');
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
},
onError: (error: Error) => showToast(error.message || 'Failed to pause print', 'error'),
});
const resumePrintMutation = useMutation({
mutationFn: () => api.resumePrint(printer.id),
onSuccess: () => {
showToast('Print resumed');
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
},
onError: (error: Error) => showToast(error.message || 'Failed to resume print', 'error'),
});
// State for tracking which AMS slot is being refreshed
const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);
// Track if we've seen the printer enter "busy" state (ams_status_main !== 0)
const seenBusyStateRef = useRef<boolean>(false);
// Fallback timeout ref
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Minimum display time passed
const minTimePassedRef = useRef<boolean>(false);
// AMS slot refresh mutation
const refreshAmsSlotMutation = useMutation({
mutationFn: ({ amsId, slotId }: { amsId: number; slotId: number }) =>
api.refreshAmsSlot(printer.id, amsId, slotId),
onMutate: ({ amsId, slotId }) => {
// Clear any existing timeout
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
// Reset state
seenBusyStateRef.current = false;
minTimePassedRef.current = false;
setRefreshingSlot({ amsId, slotId });
// Minimum display time (2 seconds)
setTimeout(() => {
minTimePassedRef.current = true;
}, 2000);
// Fallback timeout (30 seconds max)
refreshTimeoutRef.current = setTimeout(() => {
setRefreshingSlot(null);
}, 30000);
},
onSuccess: (data) => {
showToast(data.message || 'RFID re-read initiated');
},
onError: (error: Error) => {
showToast(error.message || 'Failed to re-read RFID', 'error');
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
setRefreshingSlot(null);
},
});
// Watch ams_status_main to detect when RFID read completes
// ams_status_main: 0=idle, 2=rfid_identifying
const deferredClearRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!refreshingSlot) return;
const amsStatus = status?.ams_status_main ?? 0;
// Track when we see non-idle state (printer is working)
if (amsStatus !== 0) {
seenBusyStateRef.current = true;
// Cancel any deferred clear since we're back to busy
if (deferredClearRef.current) {
clearTimeout(deferredClearRef.current);
deferredClearRef.current = null;
}
}
// When we've seen busy and now idle, clear (with min time check)
if (seenBusyStateRef.current && amsStatus === 0) {
if (minTimePassedRef.current) {
// Min time passed - clear now
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
setRefreshingSlot(null);
} else {
// Schedule clear after min time (2 seconds from start)
if (!deferredClearRef.current) {
deferredClearRef.current = setTimeout(() => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
setRefreshingSlot(null);
}, 2000);
}
}
}
return () => {
if (deferredClearRef.current) {
clearTimeout(deferredClearRef.current);
}
};
}, [status?.ams_status_main, refreshingSlot]);
// State for AMS slot menu
const [amsSlotMenu, setAmsSlotMenu] = useState<{ amsId: number; slotId: number } | null>(null);
if (shouldHide) {
return null;
}
@@ -1195,50 +1324,102 @@ function PrinterCard({
</>
)}
{/* Temperatures */}
{/* Temperatures + Print Controls */}
{status.temperatures && viewMode === 'expanded' && (() => {
// Use actual heater states from MQTT stream
const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
const bedHeating = status.temperatures.bed_heating || false;
const chamberHeating = status.temperatures.chamber_heating || false;
// Determine print state for control buttons
const isRunning = status.state === 'RUNNING';
const isPaused = status.state === 'PAUSED' || status.state === 'PAUSE';
const isPrinting = isRunning || isPaused;
const isControlBusy = stopPrintMutation.isPending || pausePrintMutation.isPending || resumePrintMutation.isPending;
return (
<div className="grid grid-cols-3 gap-3">
{/* Nozzle temp - combined for dual nozzle */}
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-orange-400" isHeating={nozzleHeating} />
{status.temperatures.nozzle_2 !== undefined ? (
<>
<p className="text-xs text-bambu-gray">Left / Right</p>
<p className="text-sm text-white">
{Math.round(status.temperatures.nozzle || 0)}°C / {Math.round(status.temperatures.nozzle_2 || 0)}°C
</p>
</>
) : (
<>
<p className="text-xs text-bambu-gray">Nozzle</p>
<p className="text-sm text-white">
{Math.round(status.temperatures.nozzle || 0)}°C
</p>
</>
)}
</div>
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-blue-400" isHeating={bedHeating} />
<p className="text-xs text-bambu-gray">Bed</p>
<p className="text-sm text-white">
{Math.round(status.temperatures.bed || 0)}°C
</p>
</div>
{status.temperatures.chamber !== undefined && (
<div className="flex gap-3">
{/* Temperature cards */}
<div className="flex-1 grid grid-cols-3 gap-2">
{/* Nozzle temp - combined for dual nozzle */}
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-green-400" isHeating={chamberHeating} />
<p className="text-xs text-bambu-gray">Chamber</p>
<p className="text-sm text-white">
{Math.round(status.temperatures.chamber || 0)}°C
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-orange-400" isHeating={nozzleHeating} />
{status.temperatures.nozzle_2 !== undefined ? (
<>
<p className="text-[10px] text-bambu-gray">L / R</p>
<p className="text-xs text-white">
{Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°
</p>
</>
) : (
<>
<p className="text-[10px] text-bambu-gray">Nozzle</p>
<p className="text-xs text-white">
{Math.round(status.temperatures.nozzle || 0)}°C
</p>
</>
)}
</div>
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-blue-400" isHeating={bedHeating} />
<p className="text-[10px] text-bambu-gray">Bed</p>
<p className="text-xs text-white">
{Math.round(status.temperatures.bed || 0)}°C
</p>
</div>
)}
{status.temperatures.chamber !== undefined ? (
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-green-400" isHeating={chamberHeating} />
<p className="text-[10px] text-bambu-gray">Chamber</p>
<p className="text-xs text-white">
{Math.round(status.temperatures.chamber || 0)}°C
</p>
</div>
) : (
<div /> /* Empty placeholder to maintain grid */
)}
</div>
{/* Print control buttons */}
<div className="flex flex-col justify-center gap-1.5 w-20">
{/* Stop button - visible when printing/paused */}
<button
onClick={() => setShowStopConfirm(true)}
disabled={!isPrinting || isControlBusy}
className={`
flex items-center justify-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium
transition-colors
${isPrinting
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
}
`}
title="Stop print"
>
<Square className="w-3 h-3" />
Stop
</button>
{/* Pause/Resume button */}
<button
onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}
disabled={!isPrinting || isControlBusy}
className={`
flex items-center justify-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium
transition-colors
${isPrinting
? isPaused
? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
: 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
: 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
}
`}
title={isPaused ? 'Resume print' : 'Pause print'}
>
{isPaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
{isPaused ? 'Resume' : 'Pause'}
</button>
</div>
</div>
);
})()}
@@ -1340,9 +1521,14 @@ function PrinterCard({
fillLevel: hasFillLevel ? tray.remain : null,
} : null;
const slotContent = (
// Check if this specific slot is being refreshed
const isRefreshing = refreshingSlot?.amsId === ams.id &&
refreshingSlot?.slotId === slotIdx;
// Slot visual content (goes inside hover card)
const slotVisual = (
<div
className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
@@ -1372,14 +1558,60 @@ function PrinterCard({
</div>
);
return filamentData ? (
<FilamentHoverCard key={slotIdx} data={filamentData}>
{slotContent}
</FilamentHoverCard>
) : (
<EmptySlotHoverCard key={slotIdx}>
{slotContent}
</EmptySlotHoverCard>
// Wrapper with menu button, dropdown, and loading overlay (outside hover card)
return (
<div key={slotIdx} className="relative group">
{/* Loading overlay during RFID re-read */}
{isRefreshing && (
<div className="absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20">
<RefreshCw className="w-4 h-4 text-bambu-green animate-spin" />
</div>
)}
{/* Menu button - appears on hover, hidden when printer busy */}
{status?.state !== 'RUNNING' && (
<button
onClick={(e) => {
e.stopPropagation();
setAmsSlotMenu(
amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx
? null
: { amsId: ams.id, slotId: slotIdx }
);
}}
className="absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary"
title="Slot options"
>
<MoreVertical className="w-2.5 h-2.5 text-bambu-gray" />
</button>
)}
{/* Dropdown menu */}
{status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx && (
<div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className="w-full px-3 py-1.5 text-left text-xs text-white hover:bg-bambu-dark-tertiary flex items-center gap-2"
onClick={(e) => {
e.stopPropagation();
refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
setAmsSlotMenu(null);
}}
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
Re-read RFID
</button>
</div>
)}
{/* Hover card wraps only the visual content */}
{filamentData ? (
<FilamentHoverCard data={filamentData}>
{slotVisual}
</FilamentHoverCard>
) : (
<EmptySlotHoverCard>
{slotVisual}
</EmptySlotHoverCard>
)}
</div>
);
})}
</div>
@@ -1419,9 +1651,15 @@ function PrinterCard({
fillLevel: hasFillLevel ? tray.remain : null,
} : null;
const slotContent = (
const htSlotId = tray?.id ?? 0;
// Check if this specific slot is being refreshed
const isHtRefreshing = refreshingSlot?.amsId === ams.id &&
refreshingSlot?.slotId === htSlotId;
// Slot visual content (goes inside hover card)
const slotVisual = (
<div
className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
@@ -1464,16 +1702,59 @@ function PrinterCard({
</div>
{/* Row 2: Slot (left) + Stats (right stacked) */}
<div className="flex gap-1.5">
{/* Slot - takes remaining width */}
{filamentData ? (
<FilamentHoverCard data={filamentData} className="flex-1">
{slotContent}
</FilamentHoverCard>
) : (
<EmptySlotHoverCard className="flex-1">
{slotContent}
</EmptySlotHoverCard>
)}
{/* Slot wrapper with menu button, dropdown, and loading overlay */}
<div className="relative group flex-1">
{/* Loading overlay during RFID re-read */}
{isHtRefreshing && (
<div className="absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20">
<RefreshCw className="w-4 h-4 text-bambu-green animate-spin" />
</div>
)}
{/* Menu button - appears on hover, hidden when printer busy */}
{status?.state !== 'RUNNING' && (
<button
onClick={(e) => {
e.stopPropagation();
setAmsSlotMenu(
amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId
? null
: { amsId: ams.id, slotId: htSlotId }
);
}}
className="absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary"
title="Slot options"
>
<MoreVertical className="w-2.5 h-2.5 text-bambu-gray" />
</button>
)}
{/* Dropdown menu */}
{status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId && (
<div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className="w-full px-3 py-1.5 text-left text-xs text-white hover:bg-bambu-dark-tertiary flex items-center gap-2"
onClick={(e) => {
e.stopPropagation();
refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
setAmsSlotMenu(null);
}}
disabled={isHtRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
Re-read RFID
</button>
</div>
)}
{/* Hover card wraps only the visual content */}
{filamentData ? (
<FilamentHoverCard data={filamentData}>
{slotVisual}
</FilamentHoverCard>
) : (
<EmptySlotHoverCard>
{slotVisual}
</EmptySlotHoverCard>
)}
</div>
{/* Stats stacked vertically: Temp on top, Humidity below */}
{(ams.humidity != null || ams.temp != null) && (
<div className="flex flex-col justify-center gap-1 shrink-0">
@@ -1749,6 +2030,51 @@ function PrinterCard({
/>
)}
{/* Stop Print Confirmation */}
{showStopConfirm && (
<ConfirmModal
title="Stop Print"
message={`Are you sure you want to stop the current print on "${printer.name}"? This will cancel the print job.`}
confirmText="Stop Print"
variant="danger"
onConfirm={() => {
stopPrintMutation.mutate();
setShowStopConfirm(false);
}}
onCancel={() => setShowStopConfirm(false)}
/>
)}
{/* Pause Print Confirmation */}
{showPauseConfirm && (
<ConfirmModal
title="Pause Print"
message={`Are you sure you want to pause the current print on "${printer.name}"?`}
confirmText="Pause Print"
variant="default"
onConfirm={() => {
pausePrintMutation.mutate();
setShowPauseConfirm(false);
}}
onCancel={() => setShowPauseConfirm(false)}
/>
)}
{/* Resume Print Confirmation */}
{showResumeConfirm && (
<ConfirmModal
title="Resume Print"
message={`Are you sure you want to resume the print on "${printer.name}"?`}
confirmText="Resume Print"
variant="default"
onConfirm={() => {
resumePrintMutation.mutate();
setShowResumeConfirm(false);
}}
onCancel={() => setShowResumeConfirm(false)}
/>
)}
{/* HMS Error Modal */}
{showHMSModal && (
<HMSErrorModal
@@ -1779,6 +2105,14 @@ function PrinterCard({
onClose={() => setShowEditModal(false)}
/>
)}
{/* AMS Slot Menu Backdrop - closes menu when clicking outside */}
{amsSlotMenu && (
<div
className="fixed inset-0 z-40"
onClick={() => setAmsSlotMenu(null)}
/>
)}
</Card>
);
}