Add clear plate confirmation between queued prints

The print scheduler previously treated FINISH/FAILED as idle states
and would auto-start the next queued print before the user cleared
the build plate. Now requires explicit user confirmation via a
"Clear Plate & Start Next" button on the printer card. Uses an
in-memory plate-cleared flag — no MQTT command needed since the
scheduler's start_print overrides the printer state.
This commit is contained in:
maziggy
2026-02-12 08:45:05 +01:00
parent 1b5683b3a3
commit aa87e5598b
18 changed files with 525 additions and 98 deletions
+1
View File
@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
### New Features
- **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
- **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).
### Improved
- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.
+1
View File
@@ -103,6 +103,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Per-printer AMS mapping (individual slot configuration for print farms)
- Scheduled prints (date/time)
- Queue Only mode (stage without auto-start)
- Clear plate confirmation between queued prints
- Smart plug integration (Tasmota, Home Assistant, MQTT)
- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
- Energy consumption tracking (per-print kWh and cost)
+31
View File
@@ -1821,6 +1821,37 @@ async def stop_print(
return {"success": True, "message": "Print stop command sent"}
@router.post("/{printer_id}/clear-plate")
async def clear_plate(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Acknowledge that the build plate has been cleared after a finished/failed print.
Sets a plate-cleared flag so the scheduler can start the next queued print.
No MQTT command is sent to the printer — the scheduler's start_print command
will override the FINISH/FAILED state when it sends the next job.
"""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
if not printer_manager.is_connected(printer_id):
raise HTTPException(400, "Printer not connected")
state = printer_manager.get_status(printer_id)
if not state or state.state not in ("FINISH", "FAILED"):
raise HTTPException(
400, f"Printer is not in FINISH or FAILED state (current: {state.state if state else 'unknown'})"
)
printer_manager.set_plate_cleared(printer_id)
return {"success": True, "message": "Plate cleared, next print will start shortly"}
@router.post("/{printer_id}/print/pause")
async def pause_print(
printer_id: int,
+8 -3
View File
@@ -694,9 +694,11 @@ class PrintScheduler:
if not state:
return False
# Printer is idle if state is IDLE, FINISH, FAILED, or unknown
# FAILED means previous print failed, printer is ready for new print
return state.state in ("IDLE", "FINISH", "FAILED", "unknown")
# IDLE = ready for next print
# FINISH/FAILED = ready only if user confirmed plate is cleared
return state.state == "IDLE" or (
state.state in ("FINISH", "FAILED") and printer_manager.is_plate_cleared(printer_id)
)
async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
"""Get the smart plug associated with a printer."""
@@ -1034,6 +1036,9 @@ class PrintScheduler:
item.status = "printing"
item.started_at = datetime.utcnow()
await db.commit()
# Consume the plate-cleared flag now that we're starting a print
printer_manager.consume_plate_cleared(item.printer_id)
logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
# Start the print with AMS mapping, plate_id and print options
+14
View File
@@ -100,6 +100,8 @@ class PrinterManager:
self._loop: asyncio.AbstractEventLoop | None = None
# Track who started the current print (Issue #206)
self._current_print_user: dict[int, dict] = {} # {printer_id: {"user_id": int, "username": str}}
# Track plate-cleared acknowledgments for queue flow
self._plate_cleared: set[int] = set() # printer_ids where user confirmed plate is cleared
def get_printer(self, printer_id: int) -> PrinterInfo | None:
"""Get printer info by ID."""
@@ -117,6 +119,18 @@ class PrinterManager:
"""Clear the current print user when print completes (Issue #206)."""
self._current_print_user.pop(printer_id, None)
def set_plate_cleared(self, printer_id: int):
"""Mark that user has cleared the build plate for this printer."""
self._plate_cleared.add(printer_id)
def is_plate_cleared(self, printer_id: int) -> bool:
"""Check if user has confirmed the plate is cleared."""
return printer_id in self._plate_cleared
def consume_plate_cleared(self, printer_id: int):
"""Clear the plate-cleared flag (called when scheduler starts next print)."""
self._plate_cleared.discard(printer_id)
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
"""Set the event loop for async callbacks."""
self._loop = loop
@@ -0,0 +1,122 @@
"""Tests for the clear plate queue flow in the print scheduler."""
from unittest.mock import MagicMock, patch
import pytest
from backend.app.services.print_scheduler import PrintScheduler
from backend.app.services.printer_manager import PrinterManager
class TestPrinterManagerPlateCleared:
"""Test the plate-cleared flag management in PrinterManager."""
@pytest.fixture
def manager(self):
return PrinterManager()
def test_plate_cleared_initially_false(self, manager):
"""No printers should have plate cleared by default."""
assert not manager.is_plate_cleared(1)
assert not manager.is_plate_cleared(999)
def test_set_plate_cleared(self, manager):
"""Setting plate cleared should make is_plate_cleared return True."""
manager.set_plate_cleared(1)
assert manager.is_plate_cleared(1)
assert not manager.is_plate_cleared(2)
def test_consume_plate_cleared(self, manager):
"""Consuming plate cleared should reset the flag."""
manager.set_plate_cleared(1)
assert manager.is_plate_cleared(1)
manager.consume_plate_cleared(1)
assert not manager.is_plate_cleared(1)
def test_consume_plate_cleared_idempotent(self, manager):
"""Consuming when not set should not raise."""
manager.consume_plate_cleared(1) # Should not raise
assert not manager.is_plate_cleared(1)
def test_set_plate_cleared_multiple_printers(self, manager):
"""Plate cleared should be tracked per printer."""
manager.set_plate_cleared(1)
manager.set_plate_cleared(3)
assert manager.is_plate_cleared(1)
assert not manager.is_plate_cleared(2)
assert manager.is_plate_cleared(3)
def test_consume_only_affects_target_printer(self, manager):
"""Consuming plate cleared for one printer should not affect others."""
manager.set_plate_cleared(1)
manager.set_plate_cleared(2)
manager.consume_plate_cleared(1)
assert not manager.is_plate_cleared(1)
assert manager.is_plate_cleared(2)
class TestSchedulerIdleCheckWithPlateCleared:
"""Test _is_printer_idle with plate-cleared flag interactions."""
@pytest.fixture
def scheduler(self):
return PrintScheduler()
@patch("backend.app.services.print_scheduler.printer_manager")
def test_idle_state_is_idle(self, mock_pm, scheduler):
"""Printer in IDLE state should be considered idle."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="IDLE")
assert scheduler._is_printer_idle(1) is True
@patch("backend.app.services.print_scheduler.printer_manager")
def test_running_state_not_idle(self, mock_pm, scheduler):
"""Printer in RUNNING state should not be idle."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="RUNNING")
assert scheduler._is_printer_idle(1) is False
@patch("backend.app.services.print_scheduler.printer_manager")
def test_finish_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
"""Printer in FINISH state should NOT be idle without plate cleared."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="FINISH")
mock_pm.is_plate_cleared.return_value = False
assert scheduler._is_printer_idle(1) is False
@patch("backend.app.services.print_scheduler.printer_manager")
def test_finish_state_idle_with_plate_cleared(self, mock_pm, scheduler):
"""Printer in FINISH state should be idle when plate is cleared."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="FINISH")
mock_pm.is_plate_cleared.return_value = True
assert scheduler._is_printer_idle(1) is True
@patch("backend.app.services.print_scheduler.printer_manager")
def test_failed_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
"""Printer in FAILED state should NOT be idle without plate cleared."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="FAILED")
mock_pm.is_plate_cleared.return_value = False
assert scheduler._is_printer_idle(1) is False
@patch("backend.app.services.print_scheduler.printer_manager")
def test_failed_state_idle_with_plate_cleared(self, mock_pm, scheduler):
"""Printer in FAILED state should be idle when plate is cleared."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="FAILED")
mock_pm.is_plate_cleared.return_value = True
assert scheduler._is_printer_idle(1) is True
@patch("backend.app.services.print_scheduler.printer_manager")
def test_disconnected_printer_not_idle(self, mock_pm, scheduler):
"""Disconnected printer should never be idle."""
mock_pm.is_connected.return_value = False
assert scheduler._is_printer_idle(1) is False
@patch("backend.app.services.print_scheduler.printer_manager")
def test_no_status_not_idle(self, mock_pm, scheduler):
"""Printer with no status should not be idle."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = None
assert scheduler._is_printer_idle(1) is False
@@ -0,0 +1,177 @@
/**
* Tests for the PrinterQueueWidget clear plate behavior.
*
* When the printer is in FINISH or FAILED state and has pending queue items,
* the widget shows a "Clear Plate & Start Next" button instead of the
* passive queue link. After clicking, it shows a confirmation state.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrinterQueueWidget } from '../../components/PrinterQueueWidget';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockQueueItems = [
{
id: 1,
printer_id: 1,
archive_id: 1,
position: 1,
status: 'pending',
archive_name: 'First Print',
printer_name: 'X1 Carbon',
print_time_seconds: 3600,
scheduled_time: null,
},
{
id: 2,
printer_id: 1,
archive_id: 2,
position: 2,
status: 'pending',
archive_name: 'Second Print',
printer_name: 'X1 Carbon',
print_time_seconds: 7200,
scheduled_time: null,
},
];
describe('PrinterQueueWidget - Clear Plate', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/queue/', ({ request }) => {
const url = new URL(request.url);
const printerId = url.searchParams.get('printer_id');
if (printerId === '1') {
return HttpResponse.json(mockQueueItems);
}
return HttpResponse.json([]);
}),
http.post('/api/v1/printers/:id/clear-plate', () => {
return HttpResponse.json({ success: true, message: 'Plate cleared' });
})
);
});
describe('clear plate button visibility', () => {
it('shows clear plate button when printer state is FINISH', async () => {
render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
});
it('shows clear plate button when printer state is FAILED', async () => {
render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
});
it('shows passive link when printer state is IDLE', async () => {
render(<PrinterQueueWidget printerId={1} printerState="IDLE" />);
await waitFor(() => {
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/queue');
});
expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
});
it('shows passive link when printer state is RUNNING', async () => {
render(<PrinterQueueWidget printerId={1} printerState="RUNNING" />);
await waitFor(() => {
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/queue');
});
});
it('shows passive link when printerState is not provided', async () => {
render(<PrinterQueueWidget printerId={1} />);
await waitFor(() => {
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/queue');
});
});
});
describe('clear plate button shows queue info', () => {
it('shows next item name in clear plate mode', async () => {
render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
await waitFor(() => {
expect(screen.getByText('First Print')).toBeInTheDocument();
});
});
it('shows additional items badge in clear plate mode', async () => {
render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
await waitFor(() => {
expect(screen.getByText('+1')).toBeInTheDocument();
});
});
});
describe('clear plate action', () => {
it('shows confirmation state after clicking clear plate', async () => {
const user = userEvent.setup();
render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
await user.click(screen.getByText('Clear Plate & Start Next'));
await waitFor(() => {
// Both the widget confirmation and the toast show this text
const elements = screen.getAllByText('Plate cleared — ready for next print');
expect(elements.length).toBeGreaterThanOrEqual(1);
});
});
it('shows error toast on API failure', async () => {
server.use(
http.post('/api/v1/printers/:id/clear-plate', () => {
return HttpResponse.json(
{ detail: 'Printer not connected' },
{ status: 400 }
);
})
);
const user = userEvent.setup();
render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
await user.click(screen.getByText('Clear Plate & Start Next'));
// Button should remain visible (not transition to success state)
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
});
});
describe('empty queue', () => {
it('renders nothing in FINISH state with no queue items', async () => {
const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" />);
await waitFor(() => {
expect(container.querySelector('button')).not.toBeInTheDocument();
});
});
});
});
+4
View File
@@ -2170,6 +2170,10 @@ export const api = {
request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
method: 'POST',
}),
clearPlate: (printerId: number) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/clear-plate`, {
method: 'POST',
}),
// Get current print user (for reprint tracking - Issue #206)
getCurrentPrintUser: (printerId: number) =>
+1 -1
View File
@@ -375,7 +375,7 @@ export function Layout() {
break;
}
}
}, [navigate, orderedSidebarIds, navItemsMap]);
}, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
+66 -4
View File
@@ -1,11 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { Clock, Calendar, ChevronRight } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Clock, Calendar, ChevronRight, Loader2, CircleCheck } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { parseUTCDate } from '../utils/date';
interface PrinterQueueWidgetProps {
printerId: number;
printerState?: string | null;
}
function formatRelativeTime(dateString: string | null): string {
@@ -22,13 +26,29 @@ function formatRelativeTime(dateString: string | null): string {
return date.toLocaleDateString();
}
export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidgetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const { data: queue } = useQuery({
queryKey: ['queue', printerId, 'pending'],
queryFn: () => api.getQueue(printerId, 'pending'),
refetchInterval: 30000,
});
const clearPlateMutation = useMutation({
mutationFn: () => api.clearPlate(printerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue', printerId] });
queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
showToast(t('queue.clearPlateSuccess'), 'success');
},
onError: (err: Error) => {
showToast(err.message, 'error');
},
});
const nextItem = queue?.[0];
const totalPending = queue?.length || 0;
@@ -36,6 +56,48 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
return null;
}
const needsClearPlate = printerState === 'FINISH' || printerState === 'FAILED';
if (needsClearPlate) {
return (
<div className="mb-3 p-3 bg-bambu-dark rounded-lg border border-yellow-400/30">
<div className="flex items-center gap-3 mb-2">
<Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
<p className="text-sm text-white truncate">
{nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
</p>
</div>
{totalPending > 1 && (
<span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded flex-shrink-0">
+{totalPending - 1}
</span>
)}
</div>
{clearPlateMutation.isSuccess ? (
<div className="w-full py-2 px-3 rounded-lg bg-bambu-green/10 border border-bambu-green/20 text-bambu-green text-sm flex items-center justify-center gap-2">
<CircleCheck className="w-4 h-4" />
{t('queue.plateReady')}
</div>
) : (
<button
onClick={() => clearPlateMutation.mutate()}
disabled={clearPlateMutation.isPending || !hasPermission('printers:control')}
className="w-full py-2 px-3 rounded-lg bg-bambu-green/20 border border-bambu-green/40 text-bambu-green hover:bg-bambu-green/30 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50"
>
{clearPlateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CircleCheck className="w-4 h-4" />
)}
{t('queue.clearPlate')}
</button>
)}
</div>
);
}
return (
<Link
to="/queue"
@@ -45,7 +107,7 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
<div className="flex items-center gap-3 min-w-0 flex-1">
<Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-bambu-gray">Next in queue</p>
<p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
<p className="text-sm text-white truncate">
{nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
</p>
+4
View File
@@ -697,6 +697,10 @@ export default {
dragToReorder: 'Ziehen zum Neuordnen (nur Sofort)',
reorderHint: 'Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.',
addedBy: 'Hinzugefügt von {{name}}',
nextInQueue: 'Nächster in der Warteschlange',
clearPlate: 'Druckplatte freigeben & Nächsten starten',
clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
// Sections
sections: {
currentlyPrinting: 'Aktuell druckend',
+4
View File
@@ -697,6 +697,10 @@ export default {
dragToReorder: 'Drag to reorder (ASAP only)',
reorderHint: 'Position only affects ASAP items. Scheduled items run at their set time.',
addedBy: 'Added by {{name}}',
nextInQueue: 'Next in queue',
clearPlate: 'Clear Plate & Start Next',
clearPlateSuccess: 'Plate cleared — ready for next print',
plateReady: 'Plate cleared — ready for next print',
// Sections
sections: {
currentlyPrinting: 'Currently Printing',
+4
View File
@@ -769,6 +769,10 @@ export default {
itemCount: '{{count}}件',
dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
addedBy: '{{username}}が追加',
nextInQueue: '次のキュー',
clearPlate: 'プレートをクリアして次を開始',
clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
sections: {
currentlyPrinting: '印刷中',
queued: 'キュー中',
+2 -4
View File
@@ -2399,10 +2399,8 @@ function PrinterCard({
</div>
</div>
{/* Queue Widget - shows next scheduled print */}
{status.state !== 'RUNNING' && (
<PrinterQueueWidget printerId={printer.id} />
)}
{/* Queue Widget - always visible when there are pending items */}
<PrinterQueueWidget printerId={printer.id} printerState={status.state} />
</>
)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-ScV2ECQD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-COjr2ipw.css">
<script type="module" crossorigin src="/assets/index-CDlz__Os.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CK9fZPad.css">
</head>
<body>
<div id="root"></div>