From 1f4da24f8e43163d39b4a1eb90f8f1f2cf7e7e97 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 16 Oct 2026 10:28:08 +0100 Subject: [PATCH 1/4] fix(notifications): hide Happy Hare toolchange M117 and merge FlowGuard clog/tangle --- BlocksScreen/lib/klipper_message_filter.py | 23 +++++++------ BlocksScreen/lib/panels/mainWindow.py | 3 +- tests/lib/test_klipper_message_filter.py | 39 ++++++++++++++++++++-- tests/panels/conftest.py | 3 ++ tests/panels/test_main_window_unit.py | 13 ++++++++ 5 files changed, 67 insertions(+), 14 deletions(-) diff --git a/BlocksScreen/lib/klipper_message_filter.py b/BlocksScreen/lib/klipper_message_filter.py index db7c22aa..53a3afd2 100644 --- a/BlocksScreen/lib/klipper_message_filter.py +++ b/BlocksScreen/lib/klipper_message_filter.py @@ -285,18 +285,12 @@ def _re(pattern: str) -> Callable[[str], bool]: hint="Replace the empty spool", severity=Severity.ERROR, ), + # FlowGuard names the stuck buffer side, not the fault, so a clog can trip "tangle". MessageRule( source=MessageSource.GCODE_ERROR, - matcher=_sub("flowguard detected a clog"), - display="Filament Clog Detected", - hint="Check the extruder for a clog", - severity=Severity.ERROR, - ), - MessageRule( - source=MessageSource.GCODE_ERROR, - matcher=_sub("flowguard detected a tangle"), - display="Filament Tangle Detected", - hint="Clear the tangle", + matcher=_re(r"flowguard detected a|(clog|tangle) has been detected"), + display="Filament Clog or Tangle", + hint="Check the nozzle and filament path", severity=Severity.ERROR, ), # ── Gcode errors — heater / temperature ─────────────────────────────────── @@ -658,6 +652,15 @@ def _re(pattern: str) -> Callable[[str], bool]: src: tuple(r for r in RULES if r.source is src) for src in MessageSource } +# Happy Hare _note_toolchange M117 forms: "T1", "> T1", "< T1", "T0 > T1", "> Bypass". +_HH_TOOL = r"(?:T\d+|Bypass|Unknown)" +_HH_TOOLCHANGE_RE = re.compile(rf"(?:[<>] )?{_HH_TOOL}(?: > {_HH_TOOL})?") + + +def is_mmu_toolchange(text: str) -> bool: + """Return True for a Happy Hare toolchange M117 status line.""" + return _HH_TOOLCHANGE_RE.fullmatch(text.strip()) is not None + def match_message(source: MessageSource, text: str) -> MessageRule | None: if any(p.search(text) for p in _IGNORED): diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 802c9dc9..d71fa274 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -12,6 +12,7 @@ from lib.klipper_message_filter import ( MessageSource, Severity, + is_mmu_toolchange, match_message, ) from lib.machine import MachineControl @@ -387,7 +388,7 @@ def __init__(self): @QtCore.pyqtSlot(str, float, name="handleDisplayUpdate") def _handle_display_status(self, name, value: str | float) -> None: if isinstance(value, str): - if value == "" or value.isspace(): + if value == "" or value.isspace() or is_mmu_toolchange(value): return self.show_notifications.emit("M117", str(value), Severity.INFO.value, True) diff --git a/tests/lib/test_klipper_message_filter.py b/tests/lib/test_klipper_message_filter.py index 3597e99d..a133c1f8 100644 --- a/tests/lib/test_klipper_message_filter.py +++ b/tests/lib/test_klipper_message_filter.py @@ -10,6 +10,7 @@ _INDEX, _re, _sub, + is_mmu_toolchange, match_message, ) @@ -260,13 +261,29 @@ def test_re_no_match() -> None: MessageSource.GCODE_ERROR, "FlowGuard detected a clog.\nReason for trip: Compression stuck after " "409.04 mm motion (triggering parameter: flowguard_max_relief)", - "Filament Clog Detected", + "Filament Clog or Tangle", Severity.ERROR, ), ( MessageSource.GCODE_ERROR, - "FlowGuard detected a tangle.\nReason for trip: Tension stuck", - "Filament Tangle Detected", + "FlowGuard detected a tangle.\nReason for trip: Tension stuck after " + "38.20 mm motion and 20.00 mm relief " + "(triggering parameter: flowguard_max_relief)", + "Filament Clog or Tangle", + Severity.ERROR, + ), + ( + MessageSource.GCODE_ERROR, + "MMU issue detected. Print will be paused\nReason: A tangle has been " + "detected on filament_tension and requires manual intervention", + "Filament Clog or Tangle", + Severity.ERROR, + ), + ( + MessageSource.GCODE_ERROR, + "MMU issue: A clog/tangle has been detected and requires manual " + "intervention", + "Filament Clog or Tangle", Severity.ERROR, ), # Gcode Echo @@ -498,3 +515,19 @@ def test_full_display_with_hint() -> None: severity=Severity.INFO, ) assert rule.full_display == "Title\nAction" + + +@pytest.mark.parametrize( + "text", + ["T0", "> T0", "< T1", "T0 > T1", "> Bypass", "< Unknown", "Unknown > T3", " T12 "], +) +def test_is_mmu_toolchange_matches_happy_hare_m117(text: str) -> None: + assert is_mmu_toolchange(text) + + +@pytest.mark.parametrize( + "text", + ["Printing T0 test", "Heating...", "T0 done", "t0", ">T0", "Layer 3 / 10", ""], +) +def test_is_mmu_toolchange_keeps_other_m117(text: str) -> None: + assert not is_mmu_toolchange(text) diff --git a/tests/panels/conftest.py b/tests/panels/conftest.py index a0677544..f49adfc3 100644 --- a/tests/panels/conftest.py +++ b/tests/panels/conftest.py @@ -12,6 +12,8 @@ from pathlib import Path from unittest.mock import MagicMock +from BlocksScreen.lib.klipper_message_filter import is_mmu_toolchange + _project_root = Path(__file__).resolve().parent.parent.parent # Other test directories (e.g. tests/lib/conftest.py) leak a bare @@ -85,6 +87,7 @@ sys.modules["lib.klipper_message_filter"].MessageSource = MagicMock() sys.modules["lib.klipper_message_filter"].Severity = MagicMock() sys.modules["lib.klipper_message_filter"].match_message = MagicMock() +sys.modules["lib.klipper_message_filter"].is_mmu_toolchange = is_mmu_toolchange sys.modules["lib.machine"].MachineControl = MagicMock sys.modules["lib.moonrakerComm"].MoonWebSocket = MagicMock sys.modules["lib.network"].WifiIconKey = MagicMock() diff --git a/tests/panels/test_main_window_unit.py b/tests/panels/test_main_window_unit.py index 0e80fe17..fbef4846 100644 --- a/tests/panels/test_main_window_unit.py +++ b/tests/panels/test_main_window_unit.py @@ -73,6 +73,19 @@ def test_skips_auto_restart(self, manual_restart_pending, ws_connected) -> None: assert fake_window._klipper_auto_restart_pending is False +class TestDisplayStatusNotification: + @pytest.mark.parametrize("message", ["> T0", "< T1", "T0 > T1", "T1"]) + def test_happy_hare_toolchange_is_not_notified(self, message) -> None: + win = SimpleNamespace(show_notifications=MagicMock()) + MainWindow._handle_display_status(win, "message", message) + win.show_notifications.emit.assert_not_called() + + def test_other_m117_is_notified(self) -> None: + win = SimpleNamespace(show_notifications=MagicMock()) + MainWindow._handle_display_status(win, "message", "Heating bed") + win.show_notifications.emit.assert_called_once() + + # Panel indices used by the print-tab invariant fixtures. _MAIN_PAGE_IDX = 0 _JOB_STATUS_IDX = 3 From a75d9859e3d617208b8917556e4131beda4d5ac3 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 16 Oct 2026 10:28:11 +0100 Subject: [PATCH 2/4] fix(moonrakerComm): keep websocket usable after a callback error --- BlocksScreen/lib/moonrakerComm.py | 7 ++----- tests/lib/test_moonraker_comm_unit.py | 8 ++++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/BlocksScreen/lib/moonrakerComm.py b/BlocksScreen/lib/moonrakerComm.py index fe505152..bb2e3144 100644 --- a/BlocksScreen/lib/moonrakerComm.py +++ b/BlocksScreen/lib/moonrakerComm.py @@ -57,7 +57,6 @@ def __init__(self, parent: QtCore.QObject) -> None: self.daemon = True self.connected = False - self.disconnected = False self._reconnect_count = 0 self._klippy_state: str | None = None self.callback_table: dict = {} @@ -185,10 +184,8 @@ def on_error(self, *args) -> None: """Websocket error callback""" # First argument is ws second is error message _error = args[1] if len(args) == 2 else args[0] - logger.error("Websocket error, disconnected: %s", _error) - with self._state_lock: - self.connected = False - self.disconnected = True + # Raising callbacks land here with the socket open; real drops end in on_close. + logger.error("Websocket error: %s", _error) def on_close(self, *args) -> None: """Websocket on close callback diff --git a/tests/lib/test_moonraker_comm_unit.py b/tests/lib/test_moonraker_comm_unit.py index 044c022b..6ac45ce1 100644 --- a/tests/lib/test_moonraker_comm_unit.py +++ b/tests/lib/test_moonraker_comm_unit.py @@ -266,6 +266,14 @@ def close_then_fail(_packet): qtbot.wait(20) assert got == [{"error": {"code": -1, "message": "disconnected"}}] + def test_callback_error_keeps_socket_usable(self, ws): + """A raising callback reaches on_error but the socket stays open.""" + ws.connected = True + ws.ws = MagicMock() + ws.on_error(ws.ws, AttributeError("'FilamentTab' has no '_material_filter'")) + assert ws.send_request("server.info") is True + ws.ws.close.assert_not_called() + def test_send_while_disconnected_never_calls_back(self, ws, qtbot): callback = MagicMock() assert ws.send_request("server.info", callback=callback) is False From 777cb621906be319b860265829c392ca5fe76b10 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 16 Oct 2026 10:49:59 +0100 Subject: [PATCH 3/4] fix(notifications): show FlowGuard clog or tangle as HH reports it --- BlocksScreen/lib/klipper_message_filter.py | 18 ++++++++++++++++-- tests/lib/test_klipper_message_filter.py | 13 ++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/BlocksScreen/lib/klipper_message_filter.py b/BlocksScreen/lib/klipper_message_filter.py index 53a3afd2..513f4f64 100644 --- a/BlocksScreen/lib/klipper_message_filter.py +++ b/BlocksScreen/lib/klipper_message_filter.py @@ -285,14 +285,28 @@ def _re(pattern: str) -> Callable[[str], bool]: hint="Replace the empty spool", severity=Severity.ERROR, ), - # FlowGuard names the stuck buffer side, not the fault, so a clog can trip "tangle". + # Same clog/tangle word Happy Hare reports and Mainsail's FlowGuard meter shows. MessageRule( source=MessageSource.GCODE_ERROR, - matcher=_re(r"flowguard detected a|(clog|tangle) has been detected"), + matcher=_sub("clog/tangle has been detected"), display="Filament Clog or Tangle", hint="Check the nozzle and filament path", severity=Severity.ERROR, ), + MessageRule( + source=MessageSource.GCODE_ERROR, + matcher=_re(r"flowguard detected a clog|a clog has been detected"), + display="Filament Clog Detected", + hint="Check the nozzle and extruder", + severity=Severity.ERROR, + ), + MessageRule( + source=MessageSource.GCODE_ERROR, + matcher=_re(r"flowguard detected a tangle|a tangle has been detected"), + display="Filament Tangle Detected", + hint="Check the spool and the feed into the MMU", + severity=Severity.ERROR, + ), # ── Gcode errors — heater / temperature ─────────────────────────────────── MessageRule( source=MessageSource.GCODE_ERROR, diff --git a/tests/lib/test_klipper_message_filter.py b/tests/lib/test_klipper_message_filter.py index a133c1f8..271c9919 100644 --- a/tests/lib/test_klipper_message_filter.py +++ b/tests/lib/test_klipper_message_filter.py @@ -261,7 +261,7 @@ def test_re_no_match() -> None: MessageSource.GCODE_ERROR, "FlowGuard detected a clog.\nReason for trip: Compression stuck after " "409.04 mm motion (triggering parameter: flowguard_max_relief)", - "Filament Clog or Tangle", + "Filament Clog Detected", Severity.ERROR, ), ( @@ -269,14 +269,21 @@ def test_re_no_match() -> None: "FlowGuard detected a tangle.\nReason for trip: Tension stuck after " "38.20 mm motion and 20.00 mm relief " "(triggering parameter: flowguard_max_relief)", - "Filament Clog or Tangle", + "Filament Tangle Detected", Severity.ERROR, ), ( MessageSource.GCODE_ERROR, "MMU issue detected. Print will be paused\nReason: A tangle has been " "detected on filament_tension and requires manual intervention", - "Filament Clog or Tangle", + "Filament Tangle Detected", + Severity.ERROR, + ), + ( + MessageSource.GCODE_ERROR, + "MMU issue detected. Print will be paused\nReason: A clog has been " + "detected on filament_compression and requires manual intervention", + "Filament Clog Detected", Severity.ERROR, ), ( From dae5a13186db2c55bb729988c27e9be9d4bd2ede Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 16 Oct 2026 14:53:20 +0100 Subject: [PATCH 4/4] fix(notifications): drop HH toolchange M117 filter, log_m117_messages covers it --- BlocksScreen/lib/klipper_message_filter.py | 9 --------- BlocksScreen/lib/panels/mainWindow.py | 3 +-- tests/lib/test_klipper_message_filter.py | 17 ----------------- tests/panels/conftest.py | 3 --- tests/panels/test_main_window_unit.py | 13 ------------- 5 files changed, 1 insertion(+), 44 deletions(-) diff --git a/BlocksScreen/lib/klipper_message_filter.py b/BlocksScreen/lib/klipper_message_filter.py index 513f4f64..89f5811d 100644 --- a/BlocksScreen/lib/klipper_message_filter.py +++ b/BlocksScreen/lib/klipper_message_filter.py @@ -666,15 +666,6 @@ def _re(pattern: str) -> Callable[[str], bool]: src: tuple(r for r in RULES if r.source is src) for src in MessageSource } -# Happy Hare _note_toolchange M117 forms: "T1", "> T1", "< T1", "T0 > T1", "> Bypass". -_HH_TOOL = r"(?:T\d+|Bypass|Unknown)" -_HH_TOOLCHANGE_RE = re.compile(rf"(?:[<>] )?{_HH_TOOL}(?: > {_HH_TOOL})?") - - -def is_mmu_toolchange(text: str) -> bool: - """Return True for a Happy Hare toolchange M117 status line.""" - return _HH_TOOLCHANGE_RE.fullmatch(text.strip()) is not None - def match_message(source: MessageSource, text: str) -> MessageRule | None: if any(p.search(text) for p in _IGNORED): diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index d71fa274..802c9dc9 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -12,7 +12,6 @@ from lib.klipper_message_filter import ( MessageSource, Severity, - is_mmu_toolchange, match_message, ) from lib.machine import MachineControl @@ -388,7 +387,7 @@ def __init__(self): @QtCore.pyqtSlot(str, float, name="handleDisplayUpdate") def _handle_display_status(self, name, value: str | float) -> None: if isinstance(value, str): - if value == "" or value.isspace() or is_mmu_toolchange(value): + if value == "" or value.isspace(): return self.show_notifications.emit("M117", str(value), Severity.INFO.value, True) diff --git a/tests/lib/test_klipper_message_filter.py b/tests/lib/test_klipper_message_filter.py index 271c9919..e51ba4b2 100644 --- a/tests/lib/test_klipper_message_filter.py +++ b/tests/lib/test_klipper_message_filter.py @@ -10,7 +10,6 @@ _INDEX, _re, _sub, - is_mmu_toolchange, match_message, ) @@ -522,19 +521,3 @@ def test_full_display_with_hint() -> None: severity=Severity.INFO, ) assert rule.full_display == "Title\nAction" - - -@pytest.mark.parametrize( - "text", - ["T0", "> T0", "< T1", "T0 > T1", "> Bypass", "< Unknown", "Unknown > T3", " T12 "], -) -def test_is_mmu_toolchange_matches_happy_hare_m117(text: str) -> None: - assert is_mmu_toolchange(text) - - -@pytest.mark.parametrize( - "text", - ["Printing T0 test", "Heating...", "T0 done", "t0", ">T0", "Layer 3 / 10", ""], -) -def test_is_mmu_toolchange_keeps_other_m117(text: str) -> None: - assert not is_mmu_toolchange(text) diff --git a/tests/panels/conftest.py b/tests/panels/conftest.py index f49adfc3..a0677544 100644 --- a/tests/panels/conftest.py +++ b/tests/panels/conftest.py @@ -12,8 +12,6 @@ from pathlib import Path from unittest.mock import MagicMock -from BlocksScreen.lib.klipper_message_filter import is_mmu_toolchange - _project_root = Path(__file__).resolve().parent.parent.parent # Other test directories (e.g. tests/lib/conftest.py) leak a bare @@ -87,7 +85,6 @@ sys.modules["lib.klipper_message_filter"].MessageSource = MagicMock() sys.modules["lib.klipper_message_filter"].Severity = MagicMock() sys.modules["lib.klipper_message_filter"].match_message = MagicMock() -sys.modules["lib.klipper_message_filter"].is_mmu_toolchange = is_mmu_toolchange sys.modules["lib.machine"].MachineControl = MagicMock sys.modules["lib.moonrakerComm"].MoonWebSocket = MagicMock sys.modules["lib.network"].WifiIconKey = MagicMock() diff --git a/tests/panels/test_main_window_unit.py b/tests/panels/test_main_window_unit.py index fbef4846..0e80fe17 100644 --- a/tests/panels/test_main_window_unit.py +++ b/tests/panels/test_main_window_unit.py @@ -73,19 +73,6 @@ def test_skips_auto_restart(self, manual_restart_pending, ws_connected) -> None: assert fake_window._klipper_auto_restart_pending is False -class TestDisplayStatusNotification: - @pytest.mark.parametrize("message", ["> T0", "< T1", "T0 > T1", "T1"]) - def test_happy_hare_toolchange_is_not_notified(self, message) -> None: - win = SimpleNamespace(show_notifications=MagicMock()) - MainWindow._handle_display_status(win, "message", message) - win.show_notifications.emit.assert_not_called() - - def test_other_m117_is_notified(self) -> None: - win = SimpleNamespace(show_notifications=MagicMock()) - MainWindow._handle_display_status(win, "message", "Heating bed") - win.show_notifications.emit.assert_called_once() - - # Panel indices used by the print-tab invariant fixtures. _MAIN_PAGE_IDX = 0 _JOB_STATUS_IDX = 3