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
120 changes: 41 additions & 79 deletions BlocksScreen/lib/moonrakerComm.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import websocket
from events import (
WebSocketDisconnected,
WebSocketError,
WebSocketMessageReceived,
WebSocketOpen,
)
Expand All @@ -31,8 +30,10 @@ class MoonWebSocket(QtCore.QObject, threading.Thread):
"""MoonWebSocket class object for creating a websocket connection to Moonraker."""

QUERY_KLIPPY_TIMEOUT: int = 2
max_retries = 3
timeout = 3
# A silently dead socket never reaches on_close without client pings
PING_INTERVAL: int = 20
PING_TIMEOUT: int = 10 # Moonraker's own pong timeout (tornado 6.5 default)

connecting_signal = QtCore.pyqtSignal([int], [str], name="websocket_connecting")
connected_signal = QtCore.pyqtSignal(name="websocket-connected")
Expand All @@ -55,13 +56,14 @@ def __init__(self, parent: QtCore.QObject) -> None:
self.daemon = True

self.connected = False
self.connecting = False
self.disconnected = False
self._reconnect_count = 0
self._klippy_state: str | None = None
self.callback_table: dict = {}

self._state_lock = threading.RLock()
self._request_lock = threading.Lock()
self._connect_lock = threading.Lock()

self._host = parent.config.get("host", parser=str, default="localhost")
self._port = parent.config.get("port", parser=int, default=7125)
Expand All @@ -71,12 +73,10 @@ def __init__(self, parent: QtCore.QObject) -> None:
self._wst = None
self._request_id = 0
self.request_table: dict = {}
self._klippy_retry_count = 0
self._moonRest = MoonRest(host=self._host, port=self._port)
self.api: MoonAPI = MoonAPI(self)
self._retry_timer: RepeatedTimer | None = None
websocket.setdefaulttimeout(self.timeout)
self._intentional_disconnect: bool = False

self.query_server_info_signal.connect(self.api.api_query_server_info)
self.query_klippy_status_timer = RepeatedTimer(
Expand All @@ -94,55 +94,31 @@ def moonRest(self) -> MoonRest:
@QtCore.pyqtSlot(name="retry_wb_conn")
def retry_wb_conn(self):
"""Retry websocket connection"""
with self._state_lock:
if self.connecting is True and self.connected is False:
return False
self.try_connection()
return self.try_connection()

@QtCore.pyqtSlot(name="try_connection")
def try_connection(self):
"""Try connecting to websocket"""
with self._state_lock:
self.connecting = True
self._reconnect_count = 0
if self._retry_timer is not None:
self._retry_timer.stopTimer()
self._retry_timer = RepeatedTimer(self.timeout, self.reconnect)
return self.connect()
"""Arm the reconnect watchdog and attempt a connection now"""
if self._retry_timer is None:
self._retry_timer = RepeatedTimer(self.timeout, self.reconnect)
else:
self._retry_timer.startTimer()
return self.reconnect()

def reconnect(self) -> bool:
"""Reconnect to websocket"""
with self._state_lock:
if self.connected:
return True
over_limit = self._reconnect_count >= self.max_retries

if over_limit:
if self._retry_timer is not None:
self._retry_timer.stopTimer()
unable_to_connect_event = WebSocketError(
data="Unable to establish connection to Websocket"
)
self.connecting_signal[int].emit(0)
with self._state_lock:
self.connecting = False
try:
instance = QtWidgets.QApplication.instance()
if instance is not None:
instance.postEvent(self.parent(), unable_to_connect_event)
else:
raise TypeError("QApplication.instance expected ad non-None value")
except Exception as e:
logger.error(
"Error on sending Event %s | Error message: %s",
unable_to_connect_event.__class__.__name__,
e,
)
logger.warning(
"Maximum number of connection retries reached, Unable to establish connection with Moonraker"
)
"""Watchdog tick, retries forever per Moonraker's client startup sequence"""
# Non-blocking: a tick and a Qt-thread call must not open two sockets
if not self._connect_lock.acquire(blocking=False):
return False
return self.connect()
try:
with self._state_lock:
if self.connected:
return True
if self._wst is not None and self._wst.is_alive():
return False # Previous attempt still in flight
return self.connect()
finally:
self._connect_lock.release()

def connect(self) -> bool:
"""Connect to websocket"""
Expand Down Expand Up @@ -178,6 +154,10 @@ def connect(self) -> bool:
self._wst = threading.Thread(
name="websocket.run_forever",
target=self.ws.run_forever,
kwargs={
"ping_interval": self.PING_INTERVAL,
"ping_timeout": self.PING_TIMEOUT,
},
daemon=True,
)
try:
Expand All @@ -191,8 +171,9 @@ def connect(self) -> bool:

def wb_disconnect(self) -> None:
"""Websocket disconnect"""
if self._retry_timer is not None:
self._retry_timer.stopTimer()
if self._wst is not None and self.ws is not None:
self._intentional_disconnect = True
self.ws.close()
if self._wst.is_alive():
self._wst.join(timeout=self.timeout + 1)
Expand Down Expand Up @@ -241,11 +222,6 @@ def on_close(self, *args) -> None:
logger.info(
f"Websocket closed, code: {_close_status_code}, message: {_close_message}"
)
if not self.connecting and not self._intentional_disconnect:
QtCore.QMetaObject.invokeMethod(
self, "try_connection", QtCore.Qt.ConnectionType.QueuedConnection
)
self._intentional_disconnect = False

@QtCore.pyqtSlot(name="evaluate_klippy_status")
def evaluate_klippy_status(self) -> None:
Expand All @@ -261,10 +237,9 @@ def on_open(self, *args) -> None:
"""
_ws = args[0] if len(args) == 1 else None
with self._state_lock:
self.connecting = False
self.connected = True
self._klippy_retry_count = 0
self._reconnect_count = 0
self._klippy_state = None # Re-emit the state after every reconnect
self.evaluate_klippy_status()
open_event = WebSocketOpen(data="Connected")
try:
Expand All @@ -277,8 +252,6 @@ def on_open(self, *args) -> None:
logger.info("Unexpected error opening websocket: %s", e)

self.connected_signal.emit()
if self._retry_timer is not None:
self._retry_timer.stopTimer()
logger.info("Connection to websocket achieved on %s", _ws)

def on_message(self, *args) -> None:
Expand Down Expand Up @@ -314,23 +287,17 @@ def on_message(self, *args) -> None:
return
if _klippy_state == "ready":
self.query_klippy_status_timer.stopTimer()
self._klippy_retry_count = 0
self.api.update_status() # Request update status immediately after klippy ready DEVDEBT
elif _klippy_state in ("startup", "disconnected"):
self._klippy_retry_count += 1
if self._klippy_retry_count >= 30:
self.query_klippy_status_timer.stopTimer()
logger.error(
"Klippy startup sequence timed out after %d retries (state=%s)",
self._klippy_retry_count,
_klippy_state,
)
elif not self.query_klippy_status_timer.running:
self.query_klippy_status_timer.startTimer()
else:
self.query_klippy_status_timer.startTimer()
self.klippy_connected_signal.emit(
_result.get("klippy_connected", False)
)
self.klippy_state_signal.emit(_klippy_state)
if _klippy_state != self._klippy_state:
# Poll repeats the state; consumers act on transitions only
self._klippy_state = _klippy_state
if _klippy_state == "ready":
self.api.update_status() # Request update status immediately after klippy ready DEVDEBT
self.klippy_state_signal.emit(_klippy_state)
return
else:
_callback = _entry[2] if len(_entry) > 2 else None
Expand All @@ -356,14 +323,9 @@ def on_message(self, *args) -> None:
)
elif "method" in response:
if response["method"] in self._KLIPPY_NOTIFY_METHODS:
self._klippy_retry_count = 0
if response["method"] == "notify_klippy_disconnected":
self._klippy_state = None # Restart may end in the same state
self.evaluate_klippy_status()
elif (
response["method"] == "notify_klippy_ready"
and not self.query_klippy_status_timer.running
):
# Poll may have hit its retry cap; re-query so the ready path still runs
self.query_server_info_signal.emit()
message_event = (
WebSocketMessageReceived( # mainly used to pass websocket notifications
method=response["method"],
Expand Down
40 changes: 26 additions & 14 deletions BlocksScreen/lib/panels/mainWindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ def __init__(self):
self._klipper_restart_timeout.setSingleShot(True)
self._klipper_restart_timeout.setInterval(30_000)
self._klipper_restart_timeout.timeout.connect(self._on_klipper_restart_timeout)
# Outlasts a RESTART's socket gap and klipper.service RestartSec=10
self._klipper_disconnect_grace = QtCore.QTimer(self)
self._klipper_disconnect_grace.setSingleShot(True)
self._klipper_disconnect_grace.setInterval(15_000)
self._klipper_disconnect_grace.timeout.connect(self._auto_restart_klipper)

usb_config = self.config.get_section("usb_manager", fallback=None)
gdir = None
Expand Down Expand Up @@ -230,7 +235,6 @@ def __init__(self):
2,
)
)
self.conn_window.retry_connection_clicked.connect(slot=self.ws.retry_wb_conn)
self.conn_window.firmware_restart_clicked.connect(
slot=self.mc.restart_klipper_mcu_service
)
Expand Down Expand Up @@ -299,7 +303,6 @@ def __init__(self):
)
self.updater_worker.rollback_done.connect(self.update_page.handle_rollback_done)
self.updater_worker.recover_done.connect(self.update_page.handle_recover_done)
self.ws.klippy_state_signal.connect(self._on_klippy_state)
self.utilitiesPanel.show_update_page.connect(self.show_update_page)
self.conn_window.update_button_clicked.connect(self.show_update_page)
self.extruder_temp_display.display_format = "upper_downer"
Expand Down Expand Up @@ -441,29 +444,38 @@ def show_update_page(self, fullscreen: bool):
@QtCore.pyqtSlot(str, name="on-klippy-state")
def _on_klippy_state(self, state: str) -> None:
self._klippy_ready = state == "ready"
if state == "disconnected":
self._klipper_disconnect_grace.start()
else:
self._klipper_disconnect_grace.stop()
if state == "shutdown":
if self._update_in_progress:
_logger.warning("Klipper E-stop detected — cancelling active update")
self.updater_worker.trigger_cancel()
elif (
state == "disconnected"
and not self._klipper_auto_restart_pending
and not self._update_in_progress
and not self.conn_window.manual_restart_pending
):
_logger.info("Klipper disconnected — auto-restarting service")
self._klipper_auto_restart_pending = True
self.loadwidget.set_status_message("Restarting Klipper...")
self.loadscreen.show()
self._klipper_restart_timeout.start()
self.ws.api.restart_service("klipper")
elif state == "ready" and self._klipper_auto_restart_pending:
_logger.info("Klipper back online after auto-restart")
self._klipper_auto_restart_pending = False
self._klipper_restart_timeout.stop()
if not self._post_update_reconnect:
self.loadscreen.hide()

@QtCore.pyqtSlot(name="auto-restart-klipper")
def _auto_restart_klipper(self) -> None:
"""Restart the klipper service once it stays disconnected past the grace."""
if (
self._klipper_auto_restart_pending
or self._update_in_progress
or self.conn_window.manual_restart_pending
or not self.ws.connected
):
return
_logger.info("Klipper disconnected — auto-restarting service")
self._klipper_auto_restart_pending = True
self.loadwidget.set_status_message("Restarting Klipper...")
self.loadscreen.show()
self._klipper_restart_timeout.start()
self.ws.api.restart_service("klipper")

@QtCore.pyqtSlot(name="arm-health-bless")
def _arm_health_bless(self) -> None:
"""On first moonraker connect, arm a one-shot to bless this build as healthy."""
Expand Down
42 changes: 18 additions & 24 deletions BlocksScreen/lib/panels/widgets/MainWindow/connectionPage.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class ConnectionPage(QtWidgets.QFrame):

_MESSAGES: typing.ClassVar[dict[ConnectionState, str]] = {
ConnectionState.DISCONNECTED: "The printer is offline.\nReconnecting automatically…",
ConnectionState.CONNECTING: "Connecting to the printer\nAttempt {n}",
ConnectionState.CONNECTING: "Connecting to the printer\nAttempting to reconnect",
ConnectionState.WEBSOCKET_LOST: "Connection interrupted.\nAttempting to reconnect…",
ConnectionState.MOONRAKER_CONNECTED: "Connection established.\nWaiting for the printer to initialise…",
ConnectionState.KLIPPER_STARTUP: "The printer is starting up.\nPlease wait…",
Expand Down Expand Up @@ -80,7 +80,6 @@ class ConnectionPage(QtWidgets.QFrame):
_RESTART_LABELS: typing.ClassVar[dict[ConnectionState, str]] = {
ConnectionState.KLIPPER_ERROR: "Firmware Restart",
ConnectionState.KLIPPER_SHUTDOWN: "Firmware Restart",
ConnectionState.WEBSOCKET_LOST: "Retry Connection",
}

_KLIPPY_STATE_MAP: typing.ClassVar[dict[str, ConnectionState]] = {
Expand All @@ -91,9 +90,6 @@ class ConnectionPage(QtWidgets.QFrame):
"ready": ConnectionState.KLIPPER_READY,
}

retry_connection_clicked: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
name="retry_connection_clicked"
)
wifi_button_clicked: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
name="call_network_page"
)
Expand Down Expand Up @@ -200,15 +196,19 @@ def _set_state(self, state: ConnectionState, context: str = "") -> None:
return
if self._handle_pending_restart(state):
return
self.dot_timer.stop()
self._state = state
message = self._MESSAGES[state].format(n=context, message=context)
self.status_label.setText(message)
message = self._MESSAGES[state].format(message=context)
if state == ConnectionState.CONNECTING:
self.base_text = message
self.dot_count = 0
self.dot_timer.start()
elif state == ConnectionState.KLIPPER_READY:
# Attempts land every 3s off the 1s beat, restarting would desync the dots
if not self.dot_timer.isActive():
self.base_text = message
self.dot_count = 0
self._add_dot()
self.dot_timer.start()
else:
self.dot_timer.stop()
self.status_label.setText(message)
self._state = state
if state == ConnectionState.KLIPPER_READY:
self._stop_restart_overlay()
self._last_shutdown_context = ""
self.hide()
Expand All @@ -224,11 +224,8 @@ def _stop_restart_overlay(self) -> None:
self.call_load_panel.emit(False, "", False)

def _add_dot(self) -> None:
self.dot_count += 1
if self.dot_count > 3:
self.dot_count = 0
dots = "." * self.dot_count
self.status_label.setText(f"{self.base_text}{dots}")
self.dot_count = self.dot_count % 3 + 1
self.status_label.setText(f"{self.base_text}{'.' * self.dot_count}")

def _update_restart_label(self, state: ConnectionState) -> None:
"""Set restart_klipper_button label based on current state."""
Expand Down Expand Up @@ -277,9 +274,6 @@ def _is_auto_recovering_context(raw: str) -> bool:
)

def _on_restart_clicked(self) -> None:
if self._state == ConnectionState.WEBSOCKET_LOST:
self.retry_connection_clicked.emit()
return
is_firmware = self._state in self._FIRMWARE_RESTART_STATES
self._last_restart_was_firmware = is_firmware
self._escalated_to_klipper_restart = False
Expand Down Expand Up @@ -324,10 +318,10 @@ def on_klippy_state(self, state: str) -> None:
else:
logger.warning("Unknown Klipper state: %s", state)

@QtCore.pyqtSlot(int, name="on_websocket_connecting")
def on_websocket_connecting(self, attempt: int) -> None:
@QtCore.pyqtSlot(name="on_websocket_connecting")
def on_websocket_connecting(self) -> None:
"""Handle websocket reconnection attempts."""
self._set_state(ConnectionState.CONNECTING, context=str(attempt))
self._set_state(ConnectionState.CONNECTING)

@QtCore.pyqtSlot(name="on_websocket_connection_achieved")
def on_websocket_connection_achieved(self) -> None:
Expand Down
Loading
Loading