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
89 changes: 74 additions & 15 deletions BlocksScreen/lib/panels/widgets/jobStatusPage.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ def __init__(self, parent) -> None:
self._displayed_layer = 0
self._last_z = 0.0
self._filament_used = 0.0
self._file_position = 0.0
self._raw_progress = 0.0
self._gcode_start_byte = 0
self._gcode_end_byte = 0
self._layer_frozen = False
self._setupUI()
self.cancel_print_dialog = BasePopup(self, floating=True)
self.tune_menu_btn.clicked.connect(self.tune_clicked.emit)
Expand Down Expand Up @@ -175,19 +180,33 @@ def handleCancel(self) -> None:
pass
self.cancel_print_dialog.open()

@QtCore.pyqtSlot(str, name="on_print_start")
def on_print_start(self, file: str) -> None:
"""Start a print job, show job status page"""
self._current_file_name = file
self.js_file_name_label.setText(self._current_file_name)
self.layer_display_button.setText("0")
def _reset_job_display(self) -> None:
"""Clear all per-job layer/progress state so a new print never shows
stale values. Runs for both screen- and Mainsail-initiated prints
(see ``on_print_start`` and the filename-change branch)."""
self.total_layers = "?"
self.total_layer_reported = False
self.layer_fallback = False
self._layer_frozen = False
self._displayed_layer = 0
self._last_z = 0.0
self._filament_used = 0.0
self._file_position = 0.0
self._raw_progress = 0.0
self._gcode_start_byte = 0
self._gcode_end_byte = 0
self._print_duration = 0.0
self.layer_display_button.setText("0")
self.layer_display_button.secondary_text = "?"
self.print_time_display_button.setText("?")
self.printing_progress_bar.reset()
self._print_duration = 0.0

@QtCore.pyqtSlot(str, name="on_print_start")
def on_print_start(self, file: str) -> None:
"""Start a print job, show job status page"""
self._current_file_name = file
self.js_file_name_label.setText(self._current_file_name)
self._reset_job_display()
self.request_file_info.emit(file)
self.print_start.emit(file)
self._post_event(events.PrintStart(self._current_file_name, self.file_metadata))
Expand All @@ -200,6 +219,8 @@ def on_fileinfo(self, metadata: dict) -> None:
self.total_layers = str(layer_count) if layer_count >= 0 else "---"
self.total_layer_reported = layer_count >= 0
self.layer_display_button.secondary_text = self.total_layers
self._gcode_start_byte = int(metadata.get("gcode_start_byte", 0) or 0)
self._gcode_end_byte = int(metadata.get("gcode_end_byte", 0) or 0)
self.file_metadata = metadata
self._load_thumbnails(*metadata.get("thumbnail_images", ()))
# Reconnect mid-print: metadata just arrived, recompute the current layer now.
Expand All @@ -213,6 +234,8 @@ def pause_resume_print(self) -> None:
10000, lambda: self.pause_printing_btn.setEnabled(True)
)
if self._internal_print_status == "printing":
# Snapshot the layer on click so the park Z-lift never bumps it.
self._layer_frozen = True
self.print_pause.emit()
if self._internal_print_status == "paused":
self.print_resume.emit()
Expand All @@ -228,13 +251,16 @@ def _handle_print_state(self, state: str) -> None:
is_invalid = lstate in self._INVALID_STATES

if lstate == "paused":
# Freeze the layer for the whole pause (covers auto-pause too).
self._layer_frozen = True
self.pause_printing_btn.setEnabled(True)
self.pause_printing_btn.setText("Resume")
self.pause_printing_btn.setPixmap(
QtGui.QPixmap(":/ui/media/btn_icons/play.svg")
)
event_state = "pause"
elif lstate == "printing":
self._layer_frozen = False
self.pause_printing_btn.setEnabled(True)
self.pause_printing_btn.setText("Pause")
self.pause_printing_btn.setPixmap(
Expand All @@ -251,6 +277,10 @@ def _handle_print_state(self, state: str) -> None:
elif is_invalid:
if lstate == "complete":
self.print_finish.emit()
# Show the finished job as 100% on its final layer.
self.printing_progress_bar.set_progress(1.0)
if self.total_layer_reported:
self.layer_display_button.setText(str(self.total_layers))
# Completed/errored print reuses the cancel page as the reprint prompt.
if lstate in ("complete", "error") and _was_active:
self.call_cancel_panel.emit(True)
Expand All @@ -259,11 +289,10 @@ def _handle_print_state(self, state: str) -> None:
_event_file = self._current_file_name
_event_meta = self.file_metadata
if is_invalid:
# Keep the final layer/total visible until the next print resets it.
self._internal_print_status = ""
self._current_file_name = ""
self.total_layers = "?"
self.total_layer_reported = False
self._displayed_layer = 0
self._layer_frozen = False
self._last_z = 0.0
self._filament_used = 0.0
self._print_duration = 0.0
Expand Down Expand Up @@ -299,9 +328,14 @@ def on_print_stats_update(self, field: str, value: dict | float | str) -> None:
if "state" in field:
self._handle_print_state(value)
elif "filename" in field:
_new_file = bool(value) and value != self._current_file_name
self._current_file_name = value
if self.js_file_name_label.text().lower() != value.lower():
self.js_file_name_label.setText(self._current_file_name)
# New file (e.g. Mainsail-started print): clear stale
# layer/progress before its metadata arrives.
if _new_file:
self._reset_job_display()
# Fetch metadata even when hidden so layers recover on reconnect.
if value:
self.request_file_info.emit(value)
Expand All @@ -318,7 +352,9 @@ def on_print_stats_update(self, field: str, value: dict | float | str) -> None:
self.total_layer_reported = False

if "current_layer" in value:
if value["current_layer"] is not None:
if self._layer_frozen:
pass # Hold the snapshot while paused.
elif value["current_layer"] is not None:
_reported_layer = int(value["current_layer"])
self.layer_display_button.setText(str(_reported_layer))
self._displayed_layer = _reported_layer
Expand Down Expand Up @@ -353,6 +389,7 @@ def _update_layer_from_z(self) -> None:
"""Recompute fallback layer from last Z on filament advance, so park/travel Z is ignored (Mainsail getPrintCurrentLayer)."""
if (
self._internal_print_status != "printing"
or self._layer_frozen # held while paused (park Z-lift ignored)
or not self.layer_fallback
or self._print_duration <= 0 # skip pre-print homing/purge moves
):
Expand Down Expand Up @@ -388,10 +425,32 @@ def virtual_sdcard_update(self, field: str, value: float | bool) -> None:
field (str): Name of the updated field on the virtual_sdcard object
value (float | bool): The updated information for the corresponding field
"""
if not self.isVisible():
return
if field == "progress":
self.printing_progress_bar.setValue(value)
# Track position/progress always so the bar is correct on next show.
if field == "file_position":
self._file_position = float(value)
elif field == "progress":
self._raw_progress = float(value)
else:
return # is_active and other fields have nothing to render
if self.isVisible():
self.printing_progress_bar.set_progress(self._compute_progress())

def _compute_progress(self) -> float:
"""File-relative progress [0, 1], matching Mainsail's default.

From Mainsail's getPrintPercentByFilepositionRelative (getters.ts):
clip file position to gcode_start_byte/gcode_end_byte so start macros
read 0% and end gcode isn't counted; fall back to virtual_sdcard.progress.
"""
start = self._gcode_start_byte
end = self._gcode_end_byte
if start and end and end > start:
if self._file_position <= start:
return 0.0
if self._file_position >= end:
return 1.0
return (self._file_position - start) / (end - start)
return self._raw_progress

def _setupUI(self) -> None:
"""Setup widget ui"""
Expand Down
22 changes: 8 additions & 14 deletions BlocksScreen/lib/utils/blocks_progressbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ class CustomProgressBar(QtWidgets.QProgressBar):
QtWidgets (QtWidget): Parent widget

Raises:
ValueError: Thrown when setting progress is not between 0.0 and 1.0
ValueError: Thrown when setting bar color is not between 0 and 255.

"""
Expand All @@ -25,7 +24,6 @@ def __init__(self, parent=None):
self._padding = 50
self._pixmap: QtGui.QPixmap = QtGui.QPixmap()
self._pixmap_cached: QtGui.QPixmap = QtGui.QPixmap()
self._pixmap_dirty: bool = True
self._bar_color = QtGui.QColor(223, 223, 223)
self.setMinimumSize(100, 100)
self._inner_rect: QtCore.QRectF = QtCore.QRectF()
Expand Down Expand Up @@ -84,19 +82,15 @@ def minimumSizeHint(self) -> QtCore.QSize:
self._inner_rect = self._calculate_inner_geometry()
return QtCore.QSize(100, 100)

def setValue(self, value: float) -> None:
"""Set progress value

Args:
value (float): Progress value between 0.0 and 1.0
def set_progress(self, fraction: float) -> None:
"""Set progress from a 0.0-1.0 fraction (clamped)."""
self.progress_value = max(0.0, min(fraction, 1.0)) * 100
self.update()

Raises:
ValueError: If provided value in not between 0.0 and 1.0
"""
if not (0 <= value <= 100):
raise ValueError("Argument `value` expected value between 0.0 and 1.0 ")
value *= 100
self.progress_value = value
def reset(self) -> None:
"""Clear progress back to 0%."""
self.progress_value = 0
super().reset()
self.update()

def set_bar_color(self, red: int, green: int, blue: int) -> None:
Expand Down
122 changes: 116 additions & 6 deletions tests/widgets/test_job_status_page_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ def set_message(self, *a):
def setPixmap(self, *a):
pass

def set_progress(self, *a): # real CustomProgressBar takes a 0-1 float
pass

return Stub


Expand Down Expand Up @@ -270,23 +273,23 @@ def test_no_update_when_hidden(self, widget):
from unittest.mock import patch

widget.hide()
with patch.object(widget.printing_progress_bar, "setValue") as mock_test:
widget.virtual_sdcard_update("progress", 50)
with patch.object(widget.printing_progress_bar, "set_progress") as mock_test:
widget.virtual_sdcard_update("progress", 0.5)
mock_test.assert_not_called()

def test_progress_field_sets_value(self, widget):
from unittest.mock import patch

widget.show()
with patch.object(widget.printing_progress_bar, "setValue") as mock_test:
widget.virtual_sdcard_update("progress", 75)
mock_test.assert_called_once_with(75)
with patch.object(widget.printing_progress_bar, "set_progress") as mock_test:
widget.virtual_sdcard_update("progress", 0.75)
mock_test.assert_called_once_with(0.75)

def test_non_progress_field_ignored(self, widget):
from unittest.mock import patch

widget.hide()
with patch.object(widget.printing_progress_bar, "setValue") as mock_test:
with patch.object(widget.printing_progress_bar, "set_progress") as mock_test:
widget.virtual_sdcard_update("is_active", True)
mock_test.assert_not_called()

Expand Down Expand Up @@ -420,3 +423,110 @@ def test_secondary_text_set_to_total_layers(self, widget):
_metadata = self._ready_widget(widget)
widget.on_fileinfo(_metadata)
assert widget.layer_display_button.secondary_text == "20"


class TestComputeProgress:
"""_compute_progress mirrors Mainsail file-relative progress."""

def _bounds(self, widget):
widget._gcode_start_byte = 100
widget._gcode_end_byte = 1100

def test_relative_midway(self, widget):
self._bounds(widget)
widget._file_position = 600
assert widget._compute_progress() == pytest.approx(0.5)

def test_before_start_is_zero(self, widget):
"""Start-macro bytes (mesh/plate cal) report 0%."""
self._bounds(widget)
widget._file_position = 50
assert widget._compute_progress() == 0.0

def test_after_end_is_one(self, widget):
"""Excluded end gcode reports 100%."""
self._bounds(widget)
widget._file_position = 5000
assert widget._compute_progress() == 1.0

def test_fallback_to_raw_without_bytes(self, widget):
widget._gcode_start_byte = 0
widget._gcode_end_byte = 0
widget._raw_progress = 0.42
assert widget._compute_progress() == 0.42


class TestResetOnNewFile:
"""A new filename clears stale layer/total; a repeat does not."""

def test_new_filename_clears_stale_total(self, widget):
widget._current_file_name = "old.gcode"
widget.total_layers = "690"
widget.layer_display_button.secondary_text = "690"
widget.on_print_stats_update("filename", "new.gcode")
assert widget.total_layers == "?"
assert widget.layer_display_button.secondary_text == "?"

def test_same_filename_does_not_reset(self, widget):
widget._current_file_name = "same.gcode"
widget.layer_display_button.secondary_text = "250"
widget.on_print_stats_update("filename", "same.gcode")
assert widget.layer_display_button.secondary_text == "250"


class TestPauseFreeze:
"""Layer is frozen across a pause so the park Z-lift never bumps it."""

def test_pause_click_freezes(self, widget):
widget._internal_print_status = "printing"
widget.pause_resume_print()
assert widget._layer_frozen is True

def test_paused_state_freezes(self, widget):
widget._handle_print_state("paused")
assert widget._layer_frozen is True

def test_printing_state_unfreezes(self, widget):
widget._layer_frozen = True
widget._handle_print_state("printing")
assert widget._layer_frozen is False

def test_frozen_ignores_reported_layer(self, widget):
widget._layer_frozen = True
widget.layer_display_button.setText("4")
widget.on_print_stats_update("info", {"current_layer": 80})
assert widget.layer_display_button.text() == "4"

def test_frozen_blocks_z_recompute(self, widget):
widget.show()
widget._internal_print_status = "printing"
widget.layer_fallback = True
widget._print_duration = 10.0
widget.file_metadata = {
"object_height": 10.0,
"layer_height": 0.2,
"first_layer_height": 0.2,
}
widget.layer_display_button.setText("4")
widget._layer_frozen = True
widget.on_gcode_move_update("gcode_position", [0, 0, 15.0, 0])
widget.on_print_stats_update("filament_used", 5.0)
assert widget.layer_display_button.text() == "4"


class TestCompletionDisplay:
"""A completed print shows the final layer=total and is not blanked."""

def _complete(self, widget):
widget._internal_print_status = "printing"
widget.total_layers = "250"
widget.total_layer_reported = True
widget._handle_print_state("complete")

def test_shows_final_layer(self, widget):
self._complete(widget)
assert widget.layer_display_button.text() == "250"

def test_total_not_blanked(self, widget):
self._complete(widget)
assert widget.total_layers == "250"
Loading
Loading