From fb3fd2e609e148e8640ffe25de883aeeac5737c0 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 15:59:07 +0100 Subject: [PATCH] fix(job-status): recompute current layer like Mainsail so it never sticks at 0 --- .../lib/panels/widgets/jobStatusPage.py | 103 +++++++++++------- tests/widgets/test_job_status_page_unit.py | 34 ++++-- 2 files changed, 90 insertions(+), 47 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/jobStatusPage.py b/BlocksScreen/lib/panels/widgets/jobStatusPage.py index e479df6b..1d42566e 100644 --- a/BlocksScreen/lib/panels/widgets/jobStatusPage.py +++ b/BlocksScreen/lib/panels/widgets/jobStatusPage.py @@ -84,7 +84,7 @@ def _post_event(self, event: QtCore.QEvent) -> None: def __init__(self, parent) -> None: super().__init__(parent) self.thumbnail_graphics = [] - self.layer_fallback = False + self._reported_layer: int | None = None self.total_layer_reported = False self._displayed_layer = 0 self._last_z = 0.0 @@ -192,7 +192,7 @@ def _reset_job_display(self) -> None: (see ``on_print_start`` and the filename-change branch).""" self.total_layers = "?" self.total_layer_reported = False - self.layer_fallback = False + self._reported_layer = None self._layer_frozen = False self._awaiting_resume = False self._resume_baseline = 0.0 @@ -222,8 +222,16 @@ def on_print_start(self, file: str) -> None: @QtCore.pyqtSlot(dict, name="on_fileinfo") def on_fileinfo(self, metadata: dict) -> None: """Handle received file info/metadata (loads regardless of visibility).""" - # Metadata has no current_layer (that's live print_stats); don't reset it here. - layer_count = metadata.get("layer_count", -1) + _meta_file = metadata.get("filename", "") + # The fileinfo signal is global: drop metadata for a file browsed mid-print. + if ( + self._current_file_name + and _meta_file + and _meta_file != self._current_file_name + ): + return + _count = metadata.get("layer_count") # Moonraker sends null when unsliced + layer_count = int(_count) if isinstance(_count, (int, float)) else -1 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 @@ -231,9 +239,7 @@ def on_fileinfo(self, metadata: dict) -> None: 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. - if self._filament_used > 0: - self._update_layer_from_z() + self._refresh_layer_display() def pause_resume_print(self) -> None: """Handle pause/resume print job button clicked""" @@ -363,16 +369,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 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 - self.layer_fallback = False - else: - # No info.current_layer from Klipper: compute from Z instead. - self.layer_fallback = True + _reported = value["current_layer"] + self._reported_layer = None if _reported is None else int(_reported) + self._refresh_layer_display() elif isinstance(value, float): # print_duration + filament_used tracked regardless of visibility (gate Z fallback) if "print_duration" in field: @@ -380,7 +379,7 @@ def on_print_stats_update(self, field: str, value: dict | float | str) -> None: elif "filament_used" in field: self._filament_used = value if value > 0: - self._update_layer_from_z() + self._refresh_layer_display() elif self.isVisible() and "total_duration" in field: _time = estimate_print_time(int(value)) _print_time_string = ( @@ -396,32 +395,62 @@ def on_gcode_move_update(self, field: str, value: list) -> None: if "gcode_position" in field and len(value) > 2: self._last_z = float(value[2]) - def _update_layer_from_z(self) -> None: - """Recompute fallback layer from last Z on filament advance, so park/travel Z is ignored (Mainsail getPrintCurrentLayer).""" + def _max_layers(self) -> int: + """Total layers: reported, metadata, geometry. + + Order from Mainsail's getPrintMaxLayers (getters.ts). + """ + if self.total_layer_reported: + try: + return int(self.total_layers) + except (TypeError, ValueError): + return 0 + meta = self.file_metadata + if not meta: + return 0 + return calculate_max_layers( + float(meta.get("object_height", 0) or 0), + float(meta.get("layer_height", 0) or 0), + float(meta.get("first_layer_height", 0) or 0), + ) + + def _layer_from_z(self) -> int: + """Z-derived layer, holds the last value when its inputs are missing. + + From Mainsail's getPrintCurrentLayer (getters.ts) Z estimate. + """ + meta = self.file_metadata 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 + or not meta ): - return - meta = self.file_metadata - if not meta: - return - layer_height = float(meta.get("layer_height", 0)) + return self._displayed_layer + layer_height = float(meta.get("layer_height", 0) or 0) if layer_height <= 0: - return - first_layer_height = float(meta.get("first_layer_height", 0)) - _max_layers = calculate_max_layers( - float(meta.get("object_height", 0)), layer_height, first_layer_height - ) - if not self.total_layer_reported and _max_layers > 0: - self.layer_display_button.secondary_text = str(_max_layers) - _current_layer = calculate_current_layer( + return self._displayed_layer + return calculate_current_layer( z_position=self._last_z, layer_height=layer_height, - first_layer_height=first_layer_height, - max_layers=_max_layers, + first_layer_height=float(meta.get("first_layer_height", 0) or 0), + max_layers=self._max_layers(), + ) + + def _refresh_layer_display(self) -> None: + """Render current/total layer: Klipper's current_layer wins, else the Z estimate. + + Mainsail's getters are computed, so the layer is re-derived on every update + instead of being armed once. + """ + if self._layer_frozen: # held while paused, park Z-lift must not bump it + return + _estimated_total = self._max_layers() + if not self.total_layer_reported and _estimated_total > 0: + self.layer_display_button.secondary_text = str(_estimated_total) + _current_layer = ( + self._layer_from_z() + if self._reported_layer is None + else self._reported_layer ) if _current_layer != self._displayed_layer: self._displayed_layer = _current_layer diff --git a/tests/widgets/test_job_status_page_unit.py b/tests/widgets/test_job_status_page_unit.py index 070e3a9a..bb7b3cff 100644 --- a/tests/widgets/test_job_status_page_unit.py +++ b/tests/widgets/test_job_status_page_unit.py @@ -2,6 +2,7 @@ import sys import types + import pytest from PyQt6 import QtWidgets @@ -54,8 +55,8 @@ def set_progress(self, *a): # real CustomProgressBar takes a 0-1 float ]: sys.modules[_name] = _mod # force-set so network conftest stubs don't win -import events # noqa: F401, E402 # ensure events is importable before jobStatusPage loads -from lib.panels.widgets.jobStatusPage import JobStatusWidget # noqa: E402 +import events # noqa: F401 # ensure events is importable before jobStatusPage loads +from lib.panels.widgets.jobStatusPage import JobStatusWidget @pytest.fixture() @@ -153,13 +154,14 @@ def test_print_duration_stored_regardless_of_visibility(self, widget): widget.on_print_stats_update("print_duration", 42.5) assert widget._print_duration == 42.5 - def test_current_layer_not_none_disables_fallback(self, widget): + def test_reported_current_layer_wins(self, widget): widget.on_print_stats_update("info", {"current_layer": 5}) - assert widget.layer_fallback is False + assert widget._reported_layer == 5 + assert widget.layer_display_button.text() == "5" - def test_current_layer_none_enables_fallback(self, widget): + def test_current_layer_none_keeps_estimate_active(self, widget): widget.on_print_stats_update("info", {"current_layer": None}) - assert widget.layer_fallback is True + assert widget._reported_layer is None def test_total_layer_value_stored(self, widget): widget.on_print_stats_update("info", {"total_layer": 120}) @@ -173,7 +175,6 @@ def _ready_widget(self, widget): """Put widget in the state where gcode_move_update should fire.""" widget.show() widget._internal_print_status = "printing" - widget.layer_fallback = True widget._print_duration = 10.0 widget.layer_display_button.setText("sentinel") widget.file_metadata = { @@ -253,7 +254,6 @@ def test_reconnect_midprint_shows_current_and_total(self, widget): """Restart mid-print: filament already used + late metadata must fill both fields.""" widget.show() widget._internal_print_status = "printing" - widget.layer_fallback = True widget._print_duration = 500.0 widget.on_print_stats_update("filament_used", 1234.0) # already printing widget.on_gcode_move_update( @@ -265,6 +265,13 @@ def test_reconnect_midprint_shows_current_and_total(self, widget): assert widget.layer_display_button.text() == "4" assert widget.layer_display_button.secondary_text != "" + def test_estimate_runs_without_any_info_update(self, widget): + """Klipper never resends an unchanged print_stats.info, so the estimate cannot wait to be armed.""" + widget.on_print_start("job.gcode") + self._ready_widget(widget) + self._feed(widget, 0.6, 1) # z=0.6 -> layer 3 + assert widget.layer_display_button.text() == "3" + class TestVirtualSdcardUpdate: """virtual_sdcard_update sets progress bard, guarded by visibility.""" @@ -386,7 +393,6 @@ def _ready_widget(self, widget) -> dict: """Put widget in the state where gcode_move_update should fire.""" widget.show() widget._internal_print_status = "printing" - widget.layer_fallback = True widget._print_duration = 10.0 return { "layer_count": 20, @@ -424,6 +430,15 @@ def test_secondary_text_set_to_total_layers(self, widget): widget.on_fileinfo(_metadata) assert widget.layer_display_button.secondary_text == "20" + def test_browsed_file_metadata_ignored_midprint(self, widget): + """fileinfo is global: a file browsed mid-print must not steal the job's totals.""" + _metadata = self._ready_widget(widget) + _metadata["filename"] = "job.gcode" + widget._current_file_name = "job.gcode" + widget.on_fileinfo(_metadata) + widget.on_fileinfo({"filename": "other.gcode", "layer_count": 999}) + assert widget.total_layers == "20" + class TestComputeProgress: """_compute_progress mirrors Mainsail file-relative progress.""" @@ -500,7 +515,6 @@ def test_frozen_ignores_reported_layer(self, widget): 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,