Skip to content

Commit de5a95e

Browse files
authored
Fix Z-Offset Calibration (#206)
# Description - [ ] Feature - [x] Bug fix - [x] Code refactor - [ ] Documentation Summary of changes to the probe calibration flow (`probeHelperPage.py`, `babystepPage.py`, `printTab.py`, `controlTab.py`, `mainWindow.py`, `moonrakerComm.py`). - Replace 3 boolean calibration flags with a `_CalibPhase` enum with 6 states - Extract `_cancel_calibration()`, `_reset_calibration_state()`, `_restore_ui()`, `_show_option_cards()` helpers - Fix double `CLEAN_NOZZLE` execution: `Z_ENDSTOP_CALIBRATE` already calls it internally - Fix `EDDY_PHASE1_RESTART` phase: prevents post-restart subscription updates from overwriting the paper-test loading message - Fix `on_extruder_update` / `on_gcode_move_update` guards: use `_calib_phase` instead of `helper_initialize` (was cleared prematurely by 300ms manual_probe query) - Fix heating message stuck after emergency stop: `notify_klippy_shutdown` now triggers `evaluate_klippy_status()` in `moonrakerComm.py`; `on_klippy_status("shutdown")` calls `_cancel_calibration()` - Add `lock_ui` signal: disables all tabs except `controlTab` and header during calibration; re-asserted when probe session becomes active - Fix `-0.000` display in `probeHelperPage` and `babystepPage` using `round(x, 3) or 0.0` - Fix save z-offset button showing for `-0.000` in `printTab` - `babystepPage`: move buttons start disabled, enabled only when `print_stats.state == "printing"` - Inline `_DISCONNECT_MSG`/`_READY_MSG` module-level dicts into `match/case` in `on_klippy_status` # Motivation The calibration flow had accumulated several interacting boolean flags that made state transitions brittle and hard to reason about. The `_CalibPhase` enum makes the state machine explicit and eliminates entire categories of bugs (premature flag clears, wrong phase handling on reconnect, no reset on emergency stop). The UI lock prevents navigating away mid-calibration. The `-0.000` fixes prevent confusing display values at the zero crossing. # Future work - Add unit tests covering the `_CalibPhase` state machine transitions
1 parent 14c4407 commit de5a95e

6 files changed

Lines changed: 517 additions & 474 deletions

File tree

BlocksScreen/lib/moonrakerComm.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,10 @@ def on_message(self, *args) -> None:
280280
metadata=_entry,
281281
)
282282
elif "method" in response:
283-
if (
284-
str(response["method"]).lower() == "notify_klippy_disconnected"
285-
): # Checkout for notify_klippy_disconnect
283+
if str(response["method"]).lower() in (
284+
"notify_klippy_disconnected",
285+
"notify_klippy_shutdown",
286+
):
286287
self.evaluate_klippy_status()
287288

288289
message_event = (

BlocksScreen/lib/panels/controlTab.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ class ControlTab(QtWidgets.QStackedWidget):
4040
disable_popups: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
4141
bool, name="disable-popups"
4242
)
43+
lock_ui: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
44+
bool, name="lock-ui"
45+
)
4346
request_numpad: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
4447
[str, int, "PyQt_PyObject"],
4548
[str, int, "PyQt_PyObject", int, int],
@@ -82,6 +85,7 @@ def __init__(
8285
self.probe_helper_page = ProbeHelper(self)
8386
self.probe_helper_page.toggle_conn_page.connect(self.toggle_conn_page)
8487
self.probe_helper_page.disable_popups.connect(self.disable_popups)
88+
self.probe_helper_page.lock_ui.connect(self.lock_ui)
8589
self.addWidget(self.probe_helper_page)
8690
self.probe_helper_page.call_load_panel.connect(self.call_load_panel)
8791
self.printcores_page = SwapPrintcorePage(self)

BlocksScreen/lib/panels/mainWindow.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434

3535
_logger = logging.getLogger(__name__)
3636

37+
_GCODE_POPUP_MESSAGES: tuple[tuple[str, str], ...] = (
38+
("filament runout", "Filament Runout"),
39+
("no filament", "No Filament Detected"),
40+
("sensor not in valid range", "Eddy Current Sensor:\nnot in valid range"),
41+
)
42+
3743

3844
def api_handler(func):
3945
"""Decorator for methods that handle api responses"""
@@ -112,6 +118,7 @@ def __init__(self):
112118
self.ui.setupUi(self)
113119
self.screensaver = ScreenSaver(self)
114120
self._popup_toggle: bool = False
121+
self._klippy_ready: bool = False
115122
self.ui.main_content_widget.setCurrentIndex(0)
116123

117124
usb_config = self.config.get_section("usb_manager", fallback=None)
@@ -145,6 +152,7 @@ def __init__(self):
145152
self.conn_window.on_websocket_connection_achieved
146153
)
147154
self.ws.connection_lost.connect(self.conn_window.on_websocket_connection_lost)
155+
self.ws.klippy_state_signal.connect(self._on_klippy_state)
148156
self.printer.webhooks_update.connect(self.conn_window.webhook_update)
149157
self.printPanel.request_back.connect(slot=self.global_back)
150158
self.printPanel.on_cancel_print.connect(slot=self.on_cancel_print)
@@ -214,7 +222,11 @@ def __init__(self):
214222
self.handle_error_response.connect(
215223
self.controlPanel.probe_helper_page.handle_error_response
216224
)
225+
self.controlPanel.probe_helper_page.show_notifications.connect(
226+
self.notiPage.new_notication
227+
)
217228
self.controlPanel.disable_popups.connect(self.popup_toggle)
229+
self.controlPanel.lock_ui.connect(self.set_ui_lock)
218230
self.on_update_message.connect(self.update_page.handle_update_message)
219231
self.update_page.request_full_update.connect(self.ws.api.full_update)
220232
self.update_page.request_recover_repo[str].connect(
@@ -432,6 +444,24 @@ def popup_toggle(self, toggle: bool) -> None:
432444
"""Toggles app popups"""
433445
self._popup_toggle = toggle
434446

447+
@QtCore.pyqtSlot(bool, name="set-ui-lock")
448+
def set_ui_lock(self, locked: bool) -> None:
449+
"""Lock or unlock navigation during calibration.
450+
451+
Disables all tabs except controlTab (where calibration lives) and
452+
the header, so the user cannot navigate away mid-calibration.
453+
"""
454+
for tab in (self.ui.printTab, self.ui.filamentTab, self.ui.utilitiesTab):
455+
self.ui.main_content_widget.setTabEnabled(
456+
self.ui.main_content_widget.indexOf(tab), not locked
457+
)
458+
self.ui.header_main_layout.setEnabled(not locked)
459+
460+
@QtCore.pyqtSlot(str, name="on-klippy-state")
461+
def _on_klippy_state(self, state: str) -> None:
462+
"""Track Klippy readiness to suppress spurious error popups during disconnect."""
463+
self._klippy_ready = state == "ready"
464+
435465
def reset_tab_indexes(self):
436466
"""
437467
Used to grantee all tabs reset to their
@@ -709,10 +739,18 @@ def _handle_notify_gcode_response_message(self, method, data, metadata) -> None:
709739
if self._popup_toggle:
710740
return
711741
_gcode_msg_type, _message = str(_gcode_response[0]).split(" ", maxsplit=1)
712-
popupWhitelist = ["filament runout", "no filament"]
713-
if _message.lower() not in popupWhitelist or _gcode_msg_type != "!!":
742+
_msg_lower = _message.lower()
743+
_display = next(
744+
(
745+
fmt
746+
for pattern, fmt in _GCODE_POPUP_MESSAGES
747+
if pattern in _msg_lower
748+
),
749+
None,
750+
)
751+
if _gcode_msg_type != "!!" or _display is None:
714752
return
715-
self.show_notifications.emit("mainwindow", _message, 3, True)
753+
self.show_notifications.emit("mainwindow", _display, 3, True)
716754

717755
@api_handler
718756
def _handle_error_message(self, method, data, metadata) -> None:
@@ -721,6 +759,11 @@ def _handle_error_message(self, method, data, metadata) -> None:
721759
if self._popup_toggle:
722760
return
723761

762+
# Suppress error popups while Klippy is disconnected/shutting down.
763+
# Those errors are side-effects of the disconnect, not actionable by the user.
764+
if not self._klippy_ready:
765+
return
766+
724767
text = data.get("message", str(data)) if isinstance(data, dict) else str(data)
725768
lower_text = text.lower()
726769

@@ -874,4 +917,4 @@ def event(self, event: QtCore.QEvent) -> bool:
874917
def sizeHint(self) -> QtCore.QSize:
875918
"""Sets default size for the widget"""
876919
self.adjustSize()
877-
return super().sizeHint(QtCore.QSize(800, 480))
920+
return QtCore.QSize(800, 480)

BlocksScreen/lib/panels/printTab.py

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,12 @@ class PrintTab(QtWidgets.QStackedWidget):
6262
on_cancel_print: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
6363
name="on_cancel_print"
6464
)
65-
call_load_panel = QtCore.pyqtSignal(bool, str, name="call-load-panel")
66-
67-
call_cancel_panel = QtCore.pyqtSignal(bool, name="call-load-panel")
65+
call_load_panel: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
66+
bool, str, name="call-load-panel"
67+
)
68+
call_cancel_panel: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
69+
bool, name="call-load-panel"
70+
)
6871

6972
def __init__(
7073
self,
@@ -75,7 +78,9 @@ def __init__(
7578
) -> None:
7679
super().__init__(parent)
7780
self._active_z_offset: float = 0.0
81+
self._pending_save_offset: float = 0.0
7882
self._finish_print_handled: bool = False
83+
self._cancel_z_snapshot: float = 0.0
7984
self._z_apply_command: str = "Z_OFFSET_APPLY_ENDSTOP"
8085

8186
self.setupMainPrintPage()
@@ -224,6 +229,9 @@ def __init__(
224229
self.printer.gcode_move_update[str, list].connect(
225230
self.babystepPage.on_gcode_move_update
226231
)
232+
self.printer.print_stats_update[str, str].connect(
233+
self.babystepPage.on_print_state_update
234+
)
227235
self.printer.gcode_move_update[str, list].connect(self.activate_save_button)
228236
self.tune_page.run_gcode.connect(self.ws.api.run_gcode)
229237
self.tune_page.request_sliderPage[str, int, "PyQt_PyObject"].connect(
@@ -268,6 +276,7 @@ def __init__(
268276
self.confirmPage_widget.on_delete.connect(self.delete_file)
269277
self.change_page(self.indexOf(self.print_page)) # force set the initial page
270278
self.save_config_btn.clicked.connect(self.save_config)
279+
self.ws.klippy_state_signal.connect(self.on_klippy_state)
271280

272281
@QtCore.pyqtSlot(str, dict, name="on_print_stats_update")
273282
@QtCore.pyqtSlot(str, float, name="on_print_stats_update")
@@ -276,10 +285,14 @@ def on_print_stats_update(self, field: str, value: dict | float | str) -> None:
276285
"""
277286
unblocks tabs if on standby
278287
"""
279-
if isinstance(value, str):
280-
if "state" in field:
281-
if value in ("standby"):
282-
self.on_cancel_print.emit()
288+
if isinstance(value, str) and "state" in field and value == "standby":
289+
self.on_cancel_print.emit()
290+
if not self._finish_print_handled and self._cancel_z_snapshot != 0:
291+
self._active_z_offset = self._cancel_z_snapshot
292+
self.save_config()
293+
self._finish_print_handled = True
294+
self.save_config_btn.setVisible(True)
295+
self._cancel_z_snapshot = 0.0
283296

284297
@QtCore.pyqtSlot(str, int, "PyQt_PyObject", name="on_numpad_request")
285298
@QtCore.pyqtSlot(str, int, "PyQt_PyObject", int, int, name="on_numpad_request")
@@ -292,6 +305,10 @@ def on_numpad_request(
292305
max_value: int = 100,
293306
) -> None:
294307
"""Handle numpad request"""
308+
try:
309+
self.numpadPage.value_selected.disconnect()
310+
except (RuntimeError, TypeError):
311+
pass
295312
self.numpadPage.value_selected.connect(callback)
296313
self.numpadPage.set_name(name)
297314
self.numpadPage.set_value(current_value)
@@ -311,6 +328,10 @@ def on_slidePage_request(
311328
max_value: int = 100,
312329
) -> None:
313330
"""Handle slider page request"""
331+
try:
332+
self.sliderPage.value_selected.disconnect()
333+
except (RuntimeError, TypeError):
334+
pass
314335
self.sliderPage.value_selected.connect(callback)
315336
self.sliderPage.set_name(name)
316337
self.sliderPage.set_slider_position(int(current_value))
@@ -334,12 +355,9 @@ def delete_file(self, filename: str, directory: str = "gcodes") -> None:
334355

335356
def save_config(self) -> None:
336357
"""Handle Save configuration behaviour, shows confirmation dialog"""
337-
338-
self.babystepPage.bbp_z_offset_title_label.setText(
339-
f"Z: {self._active_z_offset:.3f}mm"
340-
)
358+
self._pending_save_offset = self._active_z_offset
341359
self.BasePopup_z_offset.set_message(
342-
f"The Z-Offset is now {self._active_z_offset:.3f} mm.\n"
360+
f"The Z-Offset is now {self._pending_save_offset + 0.0:.3f} mm.\n"
343361
"Would you like to save this change permanently?\n"
344362
"The machine will restart."
345363
)
@@ -352,28 +370,37 @@ def save_config(self) -> None:
352370
self.BasePopup_z_offset.open()
353371

354372
def update_configuration_file(self) -> None:
355-
"""Restore the captured offset, apply it to the probe config, then save."""
373+
"""Runs the `SAVE_CONFIG` gcode"""
356374
try:
357375
self.BasePopup_z_offset.accepted.disconnect(self.update_configuration_file)
358376
except (RuntimeError, TypeError):
359377
pass
360378
self.run_gcode_signal.emit(
361-
f"SET_GCODE_OFFSET Z={self._active_z_offset:.3f} MOVE=0"
379+
f"SET_GCODE_OFFSET Z={self._pending_save_offset:.3f} MOVE=0"
362380
)
363381
self.run_gcode_signal.emit(self._z_apply_command)
364382
self.run_gcode_signal.emit("SAVE_CONFIG")
383+
self.babystepPage.bbp_z_offset_title_label.setText(
384+
f"Z: {self._pending_save_offset + 0.0:.3f}mm"
385+
)
365386
self.save_config_btn.setVisible(False)
366387

388+
@QtCore.pyqtSlot(str, name="on_klippy_state")
389+
def on_klippy_state(self, state: str) -> None:
390+
"""Dismiss the Z-offset save popup and reset save state on unexpected shutdown."""
391+
if state in ("ready", "startup"):
392+
return
393+
self.BasePopup_z_offset.reject()
394+
self.save_config_btn.setVisible(False)
395+
self.babystepPage.baby_stepchange = False
396+
367397
@QtCore.pyqtSlot(str, list, name="activate_save_button")
368398
def activate_save_button(self, name: str, value: list) -> None:
369399
"""Sync the `Save config` popup with the save_config_pending state"""
370-
if not value:
400+
if not value or name != "homing_origin" or len(value) <= 2:
371401
return
372-
373-
if name == "homing_origin":
374-
if len(value) > 2:
375-
self._active_z_offset = value[2]
376-
self.save_config_btn.setVisible(value[2] != 0)
402+
self._active_z_offset = value[2]
403+
self.save_config_btn.setVisible(round(value[2], 3) != 0)
377404

378405
def _on_delete_file_confirmed(self, filename: str, directory: str) -> None:
379406
"""Handle confirmed file deletion after user accepted the dialog."""
@@ -401,6 +428,12 @@ def setProperty(self, name: str, value: typing.Any) -> bool:
401428

402429
def handle_cancel_print(self) -> None:
403430
"""Handles the print cancel action"""
431+
if (
432+
not self._finish_print_handled
433+
and self._active_z_offset != 0
434+
and self.babystepPage.baby_stepchange
435+
):
436+
self._cancel_z_snapshot = self._active_z_offset
404437
self.ws.api.cancel_print()
405438
self.call_load_panel.emit(True, "Cancelling print...\nPlease wait")
406439

@@ -422,6 +455,7 @@ def klipper_ready_signal(self) -> None:
422455
"""React to klipper ready signal"""
423456
self.babystepPage.baby_stepchange = False
424457
self._finish_print_handled = False
458+
self._cancel_z_snapshot = 0.0
425459
self.printer.on_subscribe_config("stepper_z", self._on_stepper_z_config)
426460

427461
def _on_stepper_z_config(self, config: dict | list) -> None:
@@ -442,6 +476,7 @@ def finish_print_signal(self) -> None:
442476
if self._active_z_offset != 0 and self.babystepPage.baby_stepchange:
443477
self.save_config()
444478
self._finish_print_handled = True
479+
self.save_config_btn.setVisible(round(self._active_z_offset, 3) != 0)
445480

446481
def setupMainPrintPage(self) -> None:
447482
"""Setup UI for print page"""

0 commit comments

Comments
 (0)