Release the plate-clear gate on a powered-down printer (issue #2864)

POST /printers/{id}/clear-plate answered 400 "Printer not connected" for
anything without a live MQTT client, and the printer card hid the button
under the same condition. With Auto Power Off that is the ordinary end of
every print: the reporter's log has printer 1 marked offline at 12:00:55
by the plug and the clear-plate POST rejected at 12:03:12, with the plate
already cleared by hand. Nothing could release the gate short of powering
each printer back on, clearing, and switching it off again.

Nothing in the clear path talks to the printer. set_awaiting_plate_clear
writes an in-memory set and the printers.awaiting_plate_clear column, and
that column exists precisely so the gate survives an Auto Off cycle
(#961). The guard came in with the endpoint in aa87e5598, copied from the
stop/pause/resume handlers beside it, where reaching the printer is the
whole point. The scheduler already reads the flag off a powered-off
printer - it refuses to wake one that is still gated - and the wiki
recommends the MQTT topic for automations because it does not depend on
the printer being powered on. Only the write path disagreed.

The card follows: showClearPlateButton drops the connection term, and the
expanded-view button - which lived inside the block that renders nothing
without a live status - is shared and given its own slot below it. The
bulk filter tests clearPlate before the connection filter; every other
bulk action still needs to reach the machine. The plate pill stays
connected-only, since its only render site is inside that same block.

Two things on the same path needed the flag without a client. GET /status
returned the schema default for a printer with no cached state - manually
disconnected, or not yet reconnected after a restart - reporting a clean
plate the database disagreed with, and hiding the control on exactly the
printers that needed it. And _emit_plate_clear_change bailed when the
printer info cache was empty, which would have left the retained
plate_clear topic (#2525) asserting "awaiting" after the gate was
released; it now falls back to the row.

This does not dispatch to an unreachable printer: _is_printer_idle still
requires a connection. Releasing the gate is what lets the queue switch
the printer on for the next job instead of passing it over.
This commit is contained in:
maziggy
2026-08-18 09:34:03 +02:00
parent 607b34e94d
commit 05d87a9740
9 changed files with 263 additions and 24 deletions
@@ -496,6 +496,47 @@ describe('PrintersPage', () => {
expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
});
it('offers the clear action on a powered-down printer (#2864)', async () => {
// Auto Power Off leaves exactly this: gate up, printer unreachable. The
// control used to be hidden here, so a plate cleared by hand could not be
// acknowledged until the printer was powered back on.
let awaitingPlateClear = true;
let cleared = false;
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json([mockPrinters[0]]);
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({
...mockPrinterStatus,
connected: false,
state: 'unknown',
awaiting_plate_clear: awaitingPlateClear,
});
}),
http.post('/api/v1/printers/:id/clear-plate', () => {
cleared = true;
awaitingPlateClear = false;
return HttpResponse.json({ success: true, message: 'Plate cleared' });
})
);
render(<PrintersPage />);
const clearButton = await screen.findByRole('button', { name: 'Mark plate as cleared' });
fireEvent.click(clearButton);
await waitFor(() => {
expect(cleared).toBe(true);
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
});
});
it('updates the plate clear status after using the printer card action', async () => {
let awaitingPlateClear = true;
+40 -18
View File
@@ -2615,7 +2615,10 @@ function PrinterCard({
const lastPrint = lastPrints?.[0];
const isPrintingOrPaused = status?.state === 'RUNNING' || status?.state === 'PAUSE';
const needsPlateClear = requirePlateClear && status?.awaiting_plate_clear === true;
const showClearPlateButton = status?.connected && needsPlateClear && !isPrintingOrPaused;
// Not gated on `connected`: the plate-clear gate is Bambuddy-side state, and with
// Auto Power Off the printer is powered down exactly when the operator clears the
// plate. Hiding the control there left no way to release the gate (#2864).
const showClearPlateButton = needsPlateClear && !isPrintingOrPaused;
const activePrintName = status?.current_print && isPrintingOrPaused
? formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel)
: null;
@@ -2628,6 +2631,9 @@ function PrinterCard({
}
}, [activePrintName, needsPlateClear, status?.cover_url]);
const plateStatus = (() => {
// Connected-only because the pill's only render site sits inside the live-status
// panel. For a powered-down printer the plate-clear button itself carries the
// state — see the standalone slot below the status block (#2864).
if (!requirePlateClear || !status?.connected) return null;
if (isPrintingOrPaused) {
return {
@@ -2825,6 +2831,26 @@ function PrinterCard({
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
});
// Rendered from two places: inside the live-status block for a connected printer,
// and standalone below it for a powered-down one, whose status block isn't rendered
// at all (#2864). Shared so the two can't drift apart.
const expandedClearPlateButton = (
<button
type="button"
onClick={() => clearPlateMutation.mutate()}
disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-400/40 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
>
{clearPlateMutation.isPending ? (
<Loader2 className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] animate-spin" />
) : (
<PlateClearedIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
)}
{t('printers.plateStatus.markCleared')}
</button>
);
const nozzleTemperatureMutation = useMutation({
mutationFn: ({ target, nozzle }: { target: number; nozzle: number }) =>
api.setNozzleTemperature(printer.id, target, nozzle),
@@ -4692,22 +4718,7 @@ function PrinterCard({
);
})()}
{viewMode === 'expanded' && showClearPlateButton && (
<button
type="button"
onClick={() => clearPlateMutation.mutate()}
disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-400/40 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
>
{clearPlateMutation.isPending ? (
<Loader2 className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] animate-spin" />
) : (
<PlateClearedIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
)}
{t('printers.plateStatus.markCleared')}
</button>
)}
{viewMode === 'expanded' && showClearPlateButton && expandedClearPlateButton}
{/* Controls */}
{viewMode === 'expanded' && (() => {
@@ -6285,6 +6296,14 @@ function PrinterCard({
</>
)}
{/* Powered-down printer with a dirty plate: the status block above renders
nothing without a live connection, so the plate-clear control gets its own
slot here. Auto Power Off makes this the ordinary end-of-print state, and
the gate is Bambuddy-side — releasing it never touches the printer (#2864). */}
{printer.is_active !== false && !status?.connected && viewMode === 'expanded' && showClearPlateButton && (
expandedClearPlateButton
)}
{/* Bottom block (power row + action bar). Wrapped together so the
power row hugs the action bar at the card bottom instead of
floating up when there's less filament content above. */}
@@ -8895,12 +8914,15 @@ export function PrintersPage() {
// Filter to only applicable printers based on cached state
const applicableIds = ids.filter(id => {
const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', id]);
// clearPlate is checked before the connection filter: it only releases a
// Bambuddy-side gate, so it applies to a printer Auto Power Off has shut
// down — every other action here needs to reach the machine (#2864).
if (action === 'clearPlate') return !!(status as { awaiting_plate_clear?: boolean } | undefined)?.awaiting_plate_clear;
if (!status?.connected) return false;
switch (action) {
case 'stop': return status.state === 'RUNNING' || status.state === 'PAUSE';
case 'pause': return status.state === 'RUNNING';
case 'resume': return status.state === 'PAUSE';
case 'clearPlate': return !!(status as { awaiting_plate_clear?: boolean }).awaiting_plate_clear;
case 'clearHMS': return status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0;
default: return false;
}