diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 802c9dc9..8d5543a0 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -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") diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/connectionPage.py b/BlocksScreen/lib/panels/widgets/MainWindow/connectionPage.py index a758d72b..cc5f8d5e 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/connectionPage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/connectionPage.py @@ -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}", } @@ -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.", @@ -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( @@ -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): @@ -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) @@ -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: @@ -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: diff --git a/tests/widgets/test_connection_page.py b/tests/widgets/test_connection_page.py index 40fc9c70..3375126d 100644 --- a/tests/widgets/test_connection_page.py +++ b/tests/widgets/test_connection_page.py @@ -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