Skip to content
Open
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
103 changes: 66 additions & 37 deletions BlocksScreen/lib/panels/widgets/jobStatusPage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -222,18 +222,24 @@ 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
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.
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"""
Expand Down Expand Up @@ -363,24 +369,17 @@ 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:
self._print_duration = value
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 = (
Expand All @@ -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
Expand Down
34 changes: 24 additions & 10 deletions tests/widgets/test_job_status_page_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import sys
import types

import pytest
from PyQt6 import QtWidgets

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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})
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
Expand All @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
Loading