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
5 changes: 2 additions & 3 deletions BlocksScreen/lib/panels/mainWindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,9 +1080,8 @@ def _handle_notify_update_refreshed_message(self, method, data, metadata) -> Non
@api_handler
def _handle_printer_message(self, method, data, metadata) -> None:
"""Handle Printer messages"""
if "info" in method:
# TODO: Handle info
...
if "info" in method and isinstance(data, dict):
self.conn_window.on_printer_info(data)
if "print" in method:
if "start" in method and "ok" in data:
self.printer_state_signal.emit("printing")
Expand Down
50 changes: 47 additions & 3 deletions BlocksScreen/lib/panels/widgets/MainWindow/connectionPage.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class ConnectionPage(QtWidgets.QFrame):
ConnectionState.KLIPPER_STARTUP: "The printer is starting up.\nPlease wait…",
ConnectionState.KLIPPER_READY: "The printer is ready.",
ConnectionState.KLIPPER_DISCONNECTED: "The printer is not responding.\nPress 'Restart Printer' to reconnect.",
ConnectionState.KLIPPER_ERROR: "A printer error has occurred.\nPress 'Firmware Restart' to recover.",
ConnectionState.KLIPPER_ERROR: "A printer error has occurred.\n{message}",
ConnectionState.KLIPPER_SHUTDOWN: "The printer has shut down.\n{message}",
}

Expand All @@ -49,6 +49,13 @@ class ConnectionPage(QtWidgets.QFrame):
}
)

# Any stop before a host reboot (E button, M112, fault) leaves the MCU halted, not only the button
_STILL_HALTED: typing.Final[str] = (
"Controller board still halted\nfrom a previous stop.\n"
"If the emergency button is pressed, release it,\n"
"then press 'Firmware Restart' to recover."
)

_SHUTDOWN_REASON_MAP: typing.ClassVar[dict[str, str]] = {
"m112 command": "Emergency stop activated.\nRelease the emergency button, then\npress 'Firmware Restart' to recover.",
"printers emergency button pressed": "Emergency stop activated.\nRelease the emergency button, then\npress 'Firmware Restart' to recover.",
Expand All @@ -64,6 +71,12 @@ class ConnectionPage(QtWidgets.QFrame):
"protocol error": "MCU communication protocol error.",
"config error": "Configuration error\nCheck printer.cfg.\nPress 'Firmware Restart' to recover.",
"mcu error during connect": "Failed to connect to the MCU.",
# Klipper error-state first lines (klippy/mcu.py, serialhdl.py, extras/error_mcu.py)
"can not update mcu": _STILL_HALTED,
"command request": "Emergency stop activated.",
"unable to connect": "Cannot reach the controller board.\nCheck its power and USB cable, then\npress 'Firmware Restart'.",
"failed automated reset of mcu": "The controller board did not reset.\nPower-cycle the printer.",
"mcu protocol error": "Controller board firmware is out of date.\nThe firmware must be updated.",
}

_FIRMWARE_RESTART_STATES: typing.ClassVar[frozenset[ConnectionState]] = frozenset(
Expand Down Expand Up @@ -192,6 +205,8 @@ def _set_state(self, state: ConnectionState, context: str = "") -> None:
context = (
self._last_shutdown_context or ConnectionPage._SHUTDOWN_FALLBACK_CONTEXT
)
elif state == ConnectionState.KLIPPER_ERROR and not context:
context = ConnectionPage._SHUTDOWN_FALLBACK_CONTEXT
if not self._apply_shutdown_guard(state, context):
return
if self._handle_pending_restart(state):
Expand Down Expand Up @@ -236,11 +251,23 @@ def _update_restart_label(self, state: ConnectionState) -> None:
@staticmethod
def _strip_shutdown_prefix(raw: str) -> str:
return re.sub(
r"^(?:Shutdown due to |MCU '[^']*' shutdown[:.]\s*)",
r"^(?:Shutdown due to "
r"|(?:Previous )?MCU '[^']*' (?:shutdown[:.]|error during config\s*:)\s*"
r"|mcu '[^']*'\s*:\s*)",
"",
raw.split("\n")[0].strip(),
)

@staticmethod
def _is_halted_before_start(raw: str) -> bool:
# error_mcu.py "Previous" and mcu.py "during config": the MCU stopped before this Klipper run
return bool(
re.match(
r"(?:Previous MCU '[^']*' shutdown|MCU '[^']*' error during config)\s*:",
raw.lstrip(),
)
)

@staticmethod
def _normalize_shutdown_lines(raw: str) -> str:
line = ConnectionPage._strip_shutdown_prefix(raw)
Expand All @@ -252,10 +279,15 @@ def _clean_shutdown_context(raw: str) -> str:
lower = ConnectionPage._normalize_shutdown_lines(line)
if lower in ConnectionPage._SHUTDOWN_NOISE:
return ""
if lower.startswith("command request") and (
ConnectionPage._is_halted_before_start(raw)
):
return ConnectionPage._STILL_HALTED
for key, display in ConnectionPage._SHUTDOWN_REASON_MAP.items():
if lower.startswith(key):
return display
return line.capitalize()
# capitalize() would lowercase pin and MCU names
return line[:1].upper() + line[1:]

@staticmethod
def _build_shutdown_context(raw: str) -> str:
Expand Down Expand Up @@ -352,6 +384,18 @@ def webhook_update(self, state: str, message: str) -> None:
self._firmware_restarting_pending = True
self._restart_10s_timer.start()
self._restart_30s_timer.start()
elif state == "error":
self._set_state(
ConnectionState.KLIPPER_ERROR,
context=self._build_shutdown_context(message),
)

def on_printer_info(self, info: dict) -> None:
"""Show the reason from a printer.info reply when Klipper is in error or shutdown."""
state = info.get("state")
# A shutdown the subscription already reported would cancel the E-stop watchdog
if state == "error" or (state == "shutdown" and not self._shutdown_confirmed):
self.webhook_update(state, info.get("state_message", ""))

def showEvent(self, a0: QtGui.QShowEvent | None) -> None:
if self.conn_toggle:
Expand Down
99 changes: 99 additions & 0 deletions tests/widgets/test_connection_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,102 @@ def test_unmapped_shutdown_reason_strips_prefix(self, page):
"Shutdown due to some obscure fault"
)
assert result == "Some obscure fault"


class TestErrorMessages:
# Verbatim Klipper state_message after an E-stop + host reboot (mcu.py + error_mcu.py)
MCU_STILL_SHUTDOWN = (
"Can not update MCU 'mcu' config as it is shutdown\n"
"Once the underlying issue is corrected, use the\n"
'"FIRMWARE_RESTART" command to reset the firmware, reload the\n'
"config, and restart the host software.\n"
"Error configuring printer\n"
)

def test_mcu_still_shutdown_after_reboot(self, page):
page.webhook_update("error", self.MCU_STILL_SHUTDOWN)
text = page.status_label.text()
assert page._state == ConnectionState.KLIPPER_ERROR
assert "emergency button" in text
assert "Error configuring printer" not in text

@pytest.mark.parametrize(
("raw", "expected"),
[
("mcu 'mcu': Unable to connect", "Cannot reach the controller board"),
("Failed automated reset of MCU 'mcu'", "did not reset"),
("MCU Protocol error\n\nThis is frequently caused", "out of date"),
],
)
def test_known_error_reasons(self, page, raw, expected):
page.webhook_update("error", raw)
assert expected in page.status_label.text()

def test_previous_mcu_shutdown_after_reboot(self, page):
# Real order: server.info's bare "shutdown", then the printer.info reply; no subscription
page.on_klippy_state("shutdown")
page.on_printer_info(
{
"state": "shutdown",
"state_message": "Previous MCU 'mcu' shutdown: Command request\n"
"Once the underlying issue is corrected, use the\n"
'"FIRMWARE_RESTART" command to reset the firmware',
}
)
assert page._state == ConnectionState.KLIPPER_SHUTDOWN
assert "emergency button" in page.status_label.text()
assert page._firmware_restarting_pending is False

def test_error_during_config_after_reboot(self, page):
# mcu.py _send_get_config: the other message an E-stop + reboot can produce
page.webhook_update("error", "MCU 'mcu' error during config: Command request")
assert "emergency button" in page.status_label.text()

def test_live_command_request_is_not_previous_stop(self, page):
page.webhook_update("shutdown", "MCU 'mcu' shutdown: Command request")
text = page.status_label.text()
assert "Emergency stop activated." in text
assert "previous stop" not in text

def test_unknown_error_keeps_name_case(self, page):
page.webhook_update("error", "pin PA1 used multiple times in config")
assert "Pin PA1 used multiple times in config" in page.status_label.text()

def test_unknown_error_shows_klipper_line(self, page):
page.webhook_update(
"error", "Option 'step_pin' in section 'stepper_x' must be specified"
)
text = page.status_label.text()
assert "step_pin" in text
assert "Firmware Restart" in text

def test_empty_error_keeps_generic_text(self, page):
page.webhook_update("error", "")
assert page.status_label.text() == (
"A printer error has occurred.\nPress 'Firmware Restart' to recover."
)

def test_info_ready_and_startup_are_ignored(self, page):
page._set_state(ConnectionState.KLIPPER_ERROR)
page.webhook_update("ready", "Printer is ready")
page.webhook_update("startup", "Printer is starting")
assert page._state == ConnectionState.KLIPPER_ERROR

def test_printer_info_error_shows_reason(self, page):
page.on_printer_info(
{"state": "error", "state_message": self.MCU_STILL_SHUTDOWN}
)
assert "emergency button" in page.status_label.text()

def test_printer_info_shutdown_keeps_estop_watchdog(self, page):
# The info reply to the shutdown transition repeats what the subscription sent
estop = "Shutdown due to Printers Emergency Button Pressed."
page.webhook_update("shutdown", estop)
page.on_printer_info({"state": "shutdown", "state_message": estop})
assert page._firmware_restarting_pending is True
assert page._restart_30s_timer.isActive()

@pytest.mark.parametrize("state", ["ready", "startup"])
def test_printer_info_non_fault_is_ignored(self, page, state):
page.on_printer_info({"state": state, "state_message": "anything"})
assert page._state == ConnectionState.DISCONNECTED