Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions BlocksScreen/devices/amu/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
11 changes: 6 additions & 5 deletions BlocksScreen/lib/panels/mainWindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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)

Expand Down
24 changes: 21 additions & 3 deletions BlocksScreen/lib/panels/printTab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -56,15 +59,20 @@ 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,
parent: QtWidgets.QWidget,
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
Expand Down Expand Up @@ -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))
)
Expand Down Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions tests/amu/test_manager_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == ""
68 changes: 68 additions & 0 deletions tests/widgets/test_print_start_guard_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""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 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
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(
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
)