From fed4c518d8d0ea65e6ff920c3cb20b664baf9870 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Thu, 22 Oct 2026 10:29:51 +0100 Subject: [PATCH 1/2] fix(amu): block print starts that PRINT_START would refuse as unloaded --- BlocksScreen/devices/amu/manager.py | 10 ++++ BlocksScreen/lib/panels/mainWindow.py | 11 ++-- BlocksScreen/lib/panels/printTab.py | 24 +++++++-- tests/amu/test_manager_unit.py | 35 +++++++++++++ tests/widgets/test_print_start_guard_unit.py | 53 ++++++++++++++++++++ 5 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 tests/widgets/test_print_start_guard_unit.py diff --git a/BlocksScreen/devices/amu/manager.py b/BlocksScreen/devices/amu/manager.py index 8454a02f..5c8afc6f 100644 --- a/BlocksScreen/devices/amu/manager.py +++ b/BlocksScreen/devices/amu/manager.py @@ -239,6 +239,16 @@ def unload_refusal(self) -> str: return "Filament is already unloaded." return "" + def print_start_refusal(self) -> str: + """Return why RF50-Klipper print_start.cfg would refuse to start, or "".""" + sensors = self._mmu_state.sensors if self._mmu_state is not None else {} + # The macro tests `== False`: a missing or disabled (None) sensor passes. + if sensors.get("toolhead") is False: + return "No filament at the toolhead. Load filament before printing." + if sensors.get("mmu_pre_gate") is False: + return "No filament in the selected gate. Load filament before printing." + return "" + def _selected_gate_unloaded(self) -> GateInfo | None: """Return the selected gate when the MMU is idle-ready: enabled, homed, unloaded.""" if self._mmu_state is None or self.load_refusal(): diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 0eeb0607..93117840 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -181,7 +181,10 @@ def __init__(self): self.update_page.hide() self.conn_window.call_cancel_panel.connect(self.handle_cancel_print) self.installEventFilter(self.conn_window) - self.printPanel = PrintTab(self.printTab, self.file_data, self.ws, self.printer) + self.printPanel = PrintTab( + self.printTab, self.file_data, self.ws, self.printer, self.amu_manager + ) + self.printPanel.notify.connect(self.notiPage.new_notication) self.usb_manager.usb_mounted.connect( self.printPanel.filesPage_widget.on_usb_added ) @@ -358,10 +361,8 @@ def __init__(self): self.cancelpage.on_print_stats_update ) self.file_data.fileinfo.connect(self.cancelpage._show_screen_thumbnail) - # Reprint routes through on_print_start for the same full reset as a fresh print. - self.cancelpage.reprint_start.connect( - self.printPanel.jobStatusPage_widget.on_print_start - ) + # Reprint takes the fresh-print path: same unloaded check and full reset. + self.cancelpage.reprint_start.connect(self.printPanel.start_print) self.printPanel.call_cancel_panel.connect(self.handle_cancel_print) self.printer.display_update.connect(self._handle_display_status) diff --git a/BlocksScreen/lib/panels/printTab.py b/BlocksScreen/lib/panels/printTab.py index c6aa9bba..75d4f392 100644 --- a/BlocksScreen/lib/panels/printTab.py +++ b/BlocksScreen/lib/panels/printTab.py @@ -22,6 +22,9 @@ from lib.utils.display_button import DisplayButton from PyQt6 import QtCore, QtGui, QtWidgets +if typing.TYPE_CHECKING: + from devices.amu import AMUManager + logger = logging.getLogger(__name__) @@ -56,6 +59,9 @@ class PrintTab(QtWidgets.QStackedWidget): bool, name="call-load-panel" ) in_case_error = QtCore.pyqtSignal(name="in-case-error") + notify: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( + str, str, int, bool, name="notify" + ) def __init__( self, @@ -63,8 +69,10 @@ def __init__( file_data: Files, ws: MoonWebSocket, printer: Printer, + amu_manager: "AMUManager | None" = None, ) -> None: super().__init__(parent) + self.amu_manager: "AMUManager | None" = amu_manager self._active_z_offset: float = 0.0 self._pending_save_offset: float = 0.0 self._finish_print_handled: bool = False @@ -144,9 +152,7 @@ def __init__( self.jobStatusPage_widget = JobStatusWidget(self) self.in_case_error.connect(self.jobStatusPage_widget.handleErrors) self.addWidget(self.jobStatusPage_widget) - self.confirmPage_widget.on_accept.connect( - self.jobStatusPage_widget.on_print_start - ) + self.confirmPage_widget.on_accept.connect(self.start_print) self.jobStatusPage_widget.show_request.connect( lambda: self.change_page(self.indexOf(self.jobStatusPage_widget)) ) @@ -344,6 +350,18 @@ def on_slidePage_request( self.sliderPage.set_slider_position(int(current_value)) self.change_page(self.indexOf(self.sliderPage)) + @QtCore.pyqtSlot(str, name="start_print") + def start_print(self, file: str) -> None: + """Start *file* on the job status page unless PRINT_START would refuse it.""" + # A refused PRINT_START runs on_error CANCEL_PRINT, which HH v3 reopens as a job. + if self.amu_manager is not None and ( + reason := self.amu_manager.print_start_refusal() + ): + logger.warning("Print start of %s blocked: %s", file, reason) + self.notify.emit("PrintTab", reason, 2, True) + return + self.jobStatusPage_widget.on_print_start(file) + @QtCore.pyqtSlot(str, str, name="delete_file") @QtCore.pyqtSlot(str, name="delete_file") def delete_file(self, filename: str, directory: str = "gcodes") -> None: diff --git a/tests/amu/test_manager_unit.py b/tests/amu/test_manager_unit.py index a0927525..3aa53c2e 100644 --- a/tests/amu/test_manager_unit.py +++ b/tests/amu/test_manager_unit.py @@ -857,3 +857,38 @@ def test_states_agreeing_within_grace_disarms(self, manager, qtbot) -> None: ("print_stats", "printing"), ] assert self._run(manager, qtbot, *events) == [] + + +class TestPrintStartRefusal: + def test_no_state_allows(self, manager) -> None: + assert manager.print_start_refusal() == "" + + @pytest.mark.parametrize( + "sensors", + [ + {}, + {"toolhead": True, "mmu_pre_gate": True}, + {"toolhead": None, "mmu_pre_gate": None}, + ], + ) + def test_loaded_missing_or_disabled_allows(self, manager, sensors) -> None: + manager.update_mmu_state(_status(sensors=sensors)) + assert manager.print_start_refusal() == "" + + # .151 2026-10-21 11:10:20: toolhead False with both pre-gates True raised PRINT_START. + def test_toolhead_empty_refuses(self, manager) -> None: + manager.update_mmu_state( + _status(sensors={"toolhead": False, "mmu_pre_gate": True}) + ) + assert "toolhead" in manager.print_start_refusal() + + def test_pre_gate_empty_refuses(self, manager) -> None: + manager.update_mmu_state( + _status(sensors={"toolhead": None, "mmu_pre_gate": False}) + ) + assert "gate" in manager.print_start_refusal() + + def test_klippy_disconnect_drops_refusal(self, manager) -> None: + manager.update_mmu_state(_status(sensors={"toolhead": False})) + manager.on_klippy_state("shutdown") + assert manager.print_start_refusal() == "" diff --git a/tests/widgets/test_print_start_guard_unit.py b/tests/widgets/test_print_start_guard_unit.py new file mode 100644 index 00000000..5ecdcdd3 --- /dev/null +++ b/tests/widgets/test_print_start_guard_unit.py @@ -0,0 +1,53 @@ +"""PrintTab.start_print blocks a print PRINT_START would refuse as unloaded.""" + +import sys +import types +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +# Same stubbing and eviction as test_slider_page_request_unit.py, see there for why. +for _name in ("configfile", "lib.files", "lib.printer"): + sys.modules[_name] = types.ModuleType(_name) +sys.modules["configfile"].BlocksScreenConfig = MagicMock +sys.modules["configfile"].get_configparser = MagicMock(return_value=MagicMock()) +sys.modules["lib.files"].Files = MagicMock +sys.modules["lib.printer"].Printer = MagicMock +sys.modules.pop("lib.utils.icon_button", None) +sys.modules.pop("lib.panels.printTab", None) +sys.modules.pop("lib.ui", None) +for _stubbed in [_m for _m in sys.modules if _m.startswith("lib.panels.widgets")]: + sys.modules.pop(_stubbed, None) + +from lib.panels.printTab import PrintTab # noqa: E402 + + +def _tab(amu_manager: object) -> SimpleNamespace: + return SimpleNamespace( + amu_manager=amu_manager, + notify=MagicMock(), + jobStatusPage_widget=MagicMock(), + ) + + +class TestStartPrint: + @pytest.mark.parametrize("refusal", [None, ""]) + def test_starts_without_amu_or_refusal(self, refusal) -> None: + amu = None if refusal is None else MagicMock() + if amu is not None: + amu.print_start_refusal.return_value = refusal + tab = _tab(amu) + PrintTab.start_print(tab, "cube.gcode") + tab.jobStatusPage_widget.on_print_start.assert_called_once_with("cube.gcode") + tab.notify.emit.assert_not_called() + + def test_refusal_blocks_and_notifies(self) -> None: + amu = MagicMock() + amu.print_start_refusal.return_value = "No filament at the toolhead." + tab = _tab(amu) + PrintTab.start_print(tab, "cube.gcode") + tab.jobStatusPage_widget.on_print_start.assert_not_called() + tab.notify.emit.assert_called_once_with( + "PrintTab", "No filament at the toolhead.", 2, True + ) From a759d1393ea4c727ca0ffbeb893a8078bc814ae5 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 23 Oct 2026 09:33:44 +0100 Subject: [PATCH 2/2] test(amu): restore sys.modules after the print start guard import --- tests/widgets/test_print_start_guard_unit.py | 29 +++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/widgets/test_print_start_guard_unit.py b/tests/widgets/test_print_start_guard_unit.py index 5ecdcdd3..10e4b496 100644 --- a/tests/widgets/test_print_start_guard_unit.py +++ b/tests/widgets/test_print_start_guard_unit.py @@ -7,21 +7,36 @@ import pytest -# Same stubbing and eviction as test_slider_page_request_unit.py, see there for why. -for _name in ("configfile", "lib.files", "lib.printer"): +# Same stub/evict/restore as test_print_tab_z_offset_unit.py, see there for why. +_STUBS = ("configfile", "lib.files", "lib.printer") +_EVICT = ("lib.panels.printTab", "lib.ui", "lib.utils.icon_button") +_saved = { + m: sys.modules[m] + for m in list(sys.modules) + if m in _STUBS or m in _EVICT or m.startswith("lib.panels.widgets") +} +for _name in _STUBS: sys.modules[_name] = types.ModuleType(_name) sys.modules["configfile"].BlocksScreenConfig = MagicMock sys.modules["configfile"].get_configparser = MagicMock(return_value=MagicMock()) sys.modules["lib.files"].Files = MagicMock sys.modules["lib.printer"].Printer = MagicMock -sys.modules.pop("lib.utils.icon_button", None) -sys.modules.pop("lib.panels.printTab", None) -sys.modules.pop("lib.ui", None) -for _stubbed in [_m for _m in sys.modules if _m.startswith("lib.panels.widgets")]: - sys.modules.pop(_stubbed, None) +for _name in [ + m for m in sys.modules if m in _EVICT or m.startswith("lib.panels.widgets") +]: + sys.modules.pop(_name) from lib.panels.printTab import PrintTab # noqa: E402 +# Put the stand-ins back so this import leaves sys.modules as found. +for _name in [ + m + for m in sys.modules + if m in _STUBS or m in _EVICT or m.startswith("lib.panels.widgets") +]: + del sys.modules[_name] +sys.modules.update(_saved) + def _tab(amu_manager: object) -> SimpleNamespace: return SimpleNamespace(