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
81 changes: 51 additions & 30 deletions BlocksScreen/lib/panels/widgets/jobStatusPage.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ def __init__(self, parent) -> None:
super().__init__(parent)
self.thumbnail_graphics = []
self.layer_fallback = False
self.total_layer_reported = False
self._displayed_layer = 0
self._pending_layer = 0
self._setupUI()
self.cancel_print_dialog = BasePopup(self, floating=True)
self.tune_menu_btn.clicked.connect(self.tune_clicked.emit)
Expand All @@ -96,25 +99,21 @@ def toggle_thumbnail_expansion(self) -> None:
"""Toggle thumbnail expansion"""
if not self.thumbnail_view.scene():
return
if not self.thumbnail_view.isVisible():
self.thumbnail_view.show()
self.progressWidget.hide()
self.contentWidget.hide()
self.printing_progress_bar.hide()
self.btnWidget.hide()
self.headerWidget.hide()
self.flowrateWidget.hide()
return
self.thumbnail_view.hide()
self.progressWidget.show()
self.contentWidget.show()
self.printing_progress_bar.show()
self.flowrateWidget.show()
self.btnWidget.show()
self.headerWidget.show()
expand = not self.thumbnail_view.isVisible()
self.thumbnail_view.setVisible(expand)
for widget in (
self.progressWidget,
self.contentWidget,
self.printing_progress_bar,
self.flowrateWidget,
self.btnWidget,
self.headerWidget,
):
widget.setVisible(not expand)

def showEvent(self, a0) -> None:
"""Reimplemented method, handle `show` Event"""
super().showEvent(a0)
if self._current_file_name:
self.request_file_info.emit(self._current_file_name)

Expand Down Expand Up @@ -167,10 +166,12 @@ def handleCancel(self) -> None:
"Are you sure you \n want to cancel \n the current print job?"
)
try:
self.cancel_print_dialog.accepted.disconnect(self.print_cancel)
self.cancel_print_dialog.accepted.connect(
self.print_cancel,
QtCore.Qt.ConnectionType.UniqueConnection, # type: ignore
)
except TypeError:
pass
self.cancel_print_dialog.accepted.connect(self.print_cancel)
self.cancel_print_dialog.open()

@QtCore.pyqtSlot(str, name="on_print_start")
Expand All @@ -179,6 +180,9 @@ def on_print_start(self, file: str) -> None:
self._current_file_name = file
self.js_file_name_label.setText(self._current_file_name)
self.layer_display_button.setText("0")
self.total_layer_reported = False
self._displayed_layer = 0
self._pending_layer = 0
self.print_time_display_button.setText("?")
self.printing_progress_bar.reset()
self._print_duration = 0.0
Expand All @@ -196,6 +200,7 @@ def on_fileinfo(self, metadata: dict) -> None:
"""
layer_count = metadata.get("layer_count", -1)
self.total_layers = str(layer_count) if layer_count >= 0 else "---"
self.total_layer_reported = layer_count >= 0
self.layer_display_button.setText("0")
self.layer_display_button.secondary_text = self.total_layers
self.file_metadata = metadata
Expand All @@ -218,8 +223,9 @@ def _handle_print_state(self, state: str) -> None:
"""
lstate = state.lower()
event_state = lstate
is_valid = lstate in self._VALID_STATES
is_invalid = lstate in self._INVALID_STATES
if lstate in self._VALID_STATES:
if is_valid:
self._internal_print_status = lstate
if lstate == "paused":
self.pause_printing_btn.setEnabled(True)
Expand All @@ -235,8 +241,6 @@ def _handle_print_state(self, state: str) -> None:
QtGui.QPixmap(":/ui/media/btn_icons/pause.svg")
)
event_state = "start"

if lstate in self._VALID_STATES:
self.request_query_print_stats.emit({"print_stats": ["filename"]})
self.call_cancel_panel.emit(False)
self.show_request.emit()
Expand All @@ -251,6 +255,9 @@ def _handle_print_state(self, state: str) -> None:
self._internal_print_status = ""
self._current_file_name = ""
self.total_layers = "?"
self.total_layer_reported = False
self._displayed_layer = 0
self._pending_layer = 0
self._print_duration = 0.0
self.file_metadata = None
# Send Event on Print state
Expand All @@ -261,11 +268,14 @@ def _handle_print_state(self, state: str) -> None:
)

@QtCore.pyqtSlot(str, dict, name="flowguard_update")
def on_flowguard_update(self, str, dict):
def on_flowguard_update(self, field: str, value: dict) -> None:
"""Handle flowguard update"""
self.flowrate.setValue(dict["level"])
self.flowrate.set_max_clog(dict["max_clog"])
self.flowrate.set_max_tangle(dict["max_tangle"])
if "level" in value:
self.flowrate.setValue(value["level"])
if "max_clog" in value:
self.flowrate.set_max_clog(value["max_clog"])
if "max_tangle" in value:
self.flowrate.set_max_tangle(value["max_tangle"])

@QtCore.pyqtSlot(str, dict, name="on_print_stats_update")
@QtCore.pyqtSlot(str, float, name="on_print_stats_update")
Expand Down Expand Up @@ -293,12 +303,17 @@ def on_print_stats_update(self, field: str, value: dict | float | str) -> None:
if value["total_layer"] is not None:
self.total_layers = value["total_layer"]
self.layer_display_button.secondary_text = str(self.total_layers)
self.total_layer_reported = True
else:
self.total_layers = "---"
self.total_layer_reported = False

if "current_layer" in value:
if value["current_layer"] is not None:
self.layer_display_button.setText(f"{int(value['current_layer'])}")
_reported_layer = int(value["current_layer"])
self.layer_display_button.setText(str(_reported_layer))
self._displayed_layer = _reported_layer
self._pending_layer = _reported_layer
self.layer_fallback = False
else:
self.layer_display_button.setText("---")
Expand All @@ -307,7 +322,7 @@ def on_print_stats_update(self, field: str, value: dict | float | str) -> None:
# print_duration tracked regardless of visibility (gates Z fallback)
if "print_duration" in field:
self._print_duration = value
if self.isVisible() and "total_duration" in field:
elif self.isVisible() and "total_duration" in field:
_time = estimate_print_time(int(value))
_print_time_string = (
f"{_time[0]}Day {_time[1]}H {_time[2]}min {_time[3]} s"
Expand Down Expand Up @@ -344,19 +359,25 @@ def on_gcode_move_update(self, field: str, value: list) -> None:
first_layer_height = float(meta.get("first_layer_height", 0))
if layer_height <= 0:
return
# Mainsail getPrintMaxLayers fallback
_max_layers = calculate_max_layers(
object_height, layer_height, first_layer_height
)
if _max_layers > 0:
if not self.total_layer_reported and _max_layers > 0:
self.layer_display_button.secondary_text = str(_max_layers)
_current_layer = calculate_current_layer(
z_position=value[2],
layer_height=layer_height,
first_layer_height=first_layer_height,
max_layers=_max_layers,
)
self.layer_display_button.setText(str(_current_layer))
advance = (
_current_layer > self._displayed_layer
and _current_layer <= self._pending_layer
)
if advance:
self._displayed_layer = _current_layer
self.layer_display_button.setText(str(_current_layer))
self._pending_layer = _current_layer

@QtCore.pyqtSlot(str, float, name="virtual_sdcard_update")
@QtCore.pyqtSlot(str, bool, name="virtual_sdcard_update")
Expand Down
62 changes: 61 additions & 1 deletion tests/widgets/test_job_status_page_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,43 @@ def test_no_update_when_duration_zero(self, widget):

def test_calculates_layer_from_z(self, widget):
self._ready_widget(widget)
# z=0.4, layer_height=0.2, first=0.2 -> ceil ((0.4-0.2)/ 0.2 + 1) = 2
# z=0.4 -> layer 2; needs two samples for Z to settle before it commits.
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
assert widget.layer_display_button.text() == "2"

def test_transient_z_hop_does_not_bump_layer(self, widget):
"""A one-off Z rise (travel/Z-hop) must not advance the layer."""
self._ready_widget(widget)
# Settle at layer 2 (z=0.4).
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
assert widget.layer_display_button.text() == "2"
# Single hop up to z=1.0 (would compute layer 5) then back to 0.4.
widget.on_gcode_move_update("gcode_position", [0, 0, 1.0, 0])
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
assert widget.layer_display_button.text() == "2"

def test_layer_never_regresses(self, widget):
"""The displayed layer must not decrease within a print."""
self._ready_widget(widget)
widget.on_gcode_move_update("gcode_position", [0, 0, 0.8, 0]) # layer 4
widget.on_gcode_move_update("gcode_position", [0, 0, 0.8, 0])
assert widget.layer_display_button.text() == "4"
# Z drops (e.g. first move of an adaptive/second object) -> keep 4.
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
assert widget.layer_display_button.text() == "4"

def test_reported_total_layer_not_overridden_by_estimate(self, widget):
"""Klipper-reported total_layer wins over the geometry estimate."""
self._ready_widget(widget)
widget.on_print_stats_update("info", {"total_layer": 999})
assert widget.total_layer_reported is True
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
widget.on_gcode_move_update("gcode_position", [0, 0, 0.4, 0])
assert widget.layer_display_button.secondary_text == "999"


class TestVirtualSdcardUpdate:
"""virtual_sdcard_update sets progress bard, guarded by visibility."""
Expand Down Expand Up @@ -228,6 +261,33 @@ def test_non_progress_field_ignored(self, widget):
mock_test.assert_not_called()


class TestOnFlowguardUpdate:
"""on_flowguard_update applies only the keys present in the payload."""

def _mock_flowrate(self, widget):
from unittest.mock import MagicMock

widget.flowrate.setValue = MagicMock()
widget.flowrate.set_max_clog = MagicMock()
widget.flowrate.set_max_tangle = MagicMock()

def test_partial_payload_does_not_raise(self, widget):
self._mock_flowrate(widget)
widget.on_flowguard_update("flowguard", {"level": 42})
widget.flowrate.setValue.assert_called_once_with(42)
widget.flowrate.set_max_clog.assert_not_called()
widget.flowrate.set_max_tangle.assert_not_called()

def test_full_payload_sets_all_fields(self, widget):
self._mock_flowrate(widget)
widget.on_flowguard_update(
"flowguard", {"level": 10, "max_clog": 20, "max_tangle": 30}
)
widget.flowrate.setValue.assert_called_once_with(10)
widget.flowrate.set_max_clog.assert_called_once_with(20)
widget.flowrate.set_max_tangle.assert_called_once_with(30)


class TestPauseResumePrint:
"""pause_resume_print toggles state and emits the right signal."""

Expand Down
Loading