From 8160f64cae78c6d4311c514d923841d8024f7d30 Mon Sep 17 00:00:00 2001
From: Downtime
Date: Sat, 17 Oct 2026 13:50:38 -0500
Subject: [PATCH 1/8] Skip the LAN reconnect after a print to the selected
printer (#16271)
After a print, `Plater::print_job_finished` selects the target printer again. When that printer was already the selected LAN printer, `set_selected_machine` disconnected, reset and reconnected it for no reason. Skip the call in that case. Selecting the printer by hand still reconnects it, and cloud printers are unchanged.
---
src/slic3r/GUI/Plater.cpp | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp
index 6e4d3e34e0..d395fe104c 100644
--- a/src/slic3r/GUI/Plater.cpp
+++ b/src/slic3r/GUI/Plater.cpp
@@ -21178,7 +21178,12 @@ void Plater::print_job_finished(wxCommandEvent &evt)
Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager();
if (!dev) return;
- dev->set_selected_machine(evt.GetString().ToStdString());
+ const std::string dev_id = evt.GetString().ToStdString();
+ MachineObject* selected = dev->get_selected_machine();
+ // After a LAN print, the completion event reselects the same printer.
+ // Keep the active session while asynchronous status traffic may still be in flight.
+ if (!selected || selected->get_dev_id() != dev_id || selected->connection_type() != "lan")
+ dev->set_selected_machine(dev_id);
p->main_frame->request_select_tab(TAB_ID_MONITOR);
// Selects the status page on a built Device tab; one built by the switch starts there.
MonitorPanel* curr_monitor = MonitorPanel::if_built();
From 72ba7a2b35de67a85216552cef50484f3b089f39 Mon Sep 17 00:00:00 2001
From: Kiss Lorand <50251547+kisslorand@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:04:50 +0300
Subject: [PATCH 2/8] Fix incorrect layer number in G-code conflict warning
(#16588)
Fix layer number in G-code conflict warning
Correct the comparison used to look up a layer by Z height.
The reversed comparison could return the total layer count instead of
the layer corresponding to the reported conflict height.
---
src/libvgcode/src/Layers.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/libvgcode/src/Layers.cpp b/src/libvgcode/src/Layers.cpp
index 5e52bc7aa9..1ef6c3236a 100644
--- a/src/libvgcode/src/Layers.cpp
+++ b/src/libvgcode/src/Layers.cpp
@@ -75,7 +75,7 @@ std::vector Layers::get_zs() const
size_t Layers::get_layer_id_at(float z) const
{
- auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z < z; });
+ auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z > z; });
return std::distance(m_items.begin(), iter);
}
From 1ee0b730a1e6484e55a631dd567b41c986fc1d6a Mon Sep 17 00:00:00 2001
From: Lam Wei Lun
Date: Sun, 18 Oct 2026 03:30:21 +0800
Subject: [PATCH 3/8] Linux: use WebKit's shared-memory transport in hypervisor
guests
Web views in VirtualBox (and other virtual GPUs) fail on WebKitGTK >= 2.44
because the guest GL stack cannot back the hardware DMABUF renderer. Setting
WEBKIT_DMABUF_RENDERER_FORCE_SHM=1 fixes it and keeps accelerated compositing,
confirmed in VirtualBox on WebKitGTK 2.54.
Detect a hypervisor guest from the cpuinfo hypervisor flag before GTK init and
set the variable non-replacing, so a user-exported value still wins. Bare metal
is unchanged. The skill references are updated to match.
---
.../orca-wxwidgets/references/platforms.md | 3 ++-
.../references/webview-gl-aui-media.md | 13 +++++++++----
src/OrcaSlicer.cpp | 16 ++++++++++++++++
3 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/.claude/skills/orca-wxwidgets/references/platforms.md b/.claude/skills/orca-wxwidgets/references/platforms.md
index 0ec78aaa43..673f58357f 100644
--- a/.claude/skills/orca-wxwidgets/references/platforms.md
+++ b/.claude/skills/orca-wxwidgets/references/platforms.md
@@ -574,7 +574,8 @@ DMABUF workaround and then branches on `GDK_BACKEND` (an `x11` prefix means the
paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44,
`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and
`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware
-DMABUF renderer. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
+DMABUF renderer. On a hypervisor guest (`/proc/cpuinfo` `hypervisor` flag) and WebKitGTK >= 2.44 it sets
+`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` alone, since virtual GPUs cannot back the hardware path either. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked
WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case
that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native
diff --git a/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md b/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md
index e7ff4fadde..e707c99ebd 100644
--- a/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md
+++ b/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md
@@ -568,13 +568,18 @@ vue-resize workaround script and sets a persistent SQLite cookie store on the vi
## WebKitGTK on Linux sessions
**Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises)
-sets the NVIDIA DMABUF workaround and then branches on `GDK_BACKEND`:
+sets the DMABUF workarounds and then branches on `GDK_BACKEND`:
- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK
honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The
proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps
accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the
Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check.
+- **Hypervisor guests**, same position: when `is_hypervisor_guest()` (the `hypervisor` flag in
+ `/proc/cpuinfo`) and WebKitGTK >= 2.44, it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` non-replacing.
+ Virtual GPUs (VirtualBox VMSVGA, VMware, QEMU) cannot back the hardware DMABUF path either, and
+ FORCE_SHM alone was confirmed to fix web views in VirtualBox; the `WEBKIT_FORCE_DMABUF_RENDERER` pair
+ is not needed without the Debian `isNVIDIA()` patch. The flag is x86-only, so ARM guests are not covered.
- **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets
`WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42
@@ -590,9 +595,9 @@ Runtime backend detection elsewhere uses Orca's `is_running_on_wayland()` / `is_
(see `references/platforms.md`), not these environment variables.
**Pitfall.**
-- **Rule:** On NVIDIA reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
- `WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver cannot back WebKit's hardware
- DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
+- **Rule:** On NVIDIA and hypervisor guests reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
+ `WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver and virtual GPUs cannot back WebKit's
+ hardware DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate,
release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a
substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the
diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp
index 468cfb9a30..48ddd16a3b 100644
--- a/src/OrcaSlicer.cpp
+++ b/src/OrcaSlicer.cpp
@@ -1126,6 +1126,19 @@ static void load_downward_settings_list_from_config(std::string config_file, std
}
}
+#ifdef __WXGTK__
+// Virtual GPUs (VirtualBox, VMware, QEMU) cannot back WebKit's hardware DMABUF renderer.
+static bool is_hypervisor_guest()
+{
+ std::ifstream cpuinfo("/proc/cpuinfo");
+ std::string line;
+ while (std::getline(cpuinfo, line))
+ if (boost::starts_with(line, "flags"))
+ return line.find(" hypervisor") != std::string::npos;
+ return false;
+}
+#endif
+
int CLI::run(int argc, char **argv)
{
// Mark the main thread for the debugger and for runtime checks.
@@ -1176,6 +1189,9 @@ int CLI::run(int argc, char **argv)
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false);
}
+ if (is_hypervisor_guest() && WebView::SupportsForceSharedMemory())
+ ::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /* replace */ false);
+
if (x11_opt_in) {
// ===== X11 / XWayland (opted in via GDK_BACKEND=x11) =====
BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds.";
From c1fb801ff33c13bb667e32a58a8c9165fb1406cb Mon Sep 17 00:00:00 2001
From: yw4z
Date: Sun, 18 Oct 2026 17:45:30 +0300
Subject: [PATCH 4/8] Linux: Show display backend of app on Troubleshoot dialog
(#16615)
* init
* Update TroubleshootDialog.cpp
* fix build
---
src/slic3r/GUI/TroubleshootDialog.cpp | 24 +++++++++++++++++++++++-
src/slic3r/GUI/TroubleshootDialog.hpp | 1 +
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/src/slic3r/GUI/TroubleshootDialog.cpp b/src/slic3r/GUI/TroubleshootDialog.cpp
index 20ca84d220..82aa4b659c 100644
--- a/src/slic3r/GUI/TroubleshootDialog.cpp
+++ b/src/slic3r/GUI/TroubleshootDialog.cpp
@@ -74,6 +74,7 @@
#endif
#ifdef __LINUX__
+#include "LinuxDisplayBackend.hpp"
#include
#include
#include
@@ -201,7 +202,11 @@ TroubleshootDialog::TroubleshootDialog()
return std::vector{GetOStype()};
return std::vector{
GetOSinfo(),
+#ifdef __LINUX__
+ GetPackageType() + GetAppDisplayBackend(),
+#else
GetPackageType(),
+#endif
GetCPUinfo(),
GetRAMinfo() + " RAM",
GetGPUinfo(),
@@ -434,10 +439,15 @@ wxString TroubleshootDialog::GetTimestamp()
wxString TroubleshootDialog::GetSysInfoAll()
{
+ wxString package = GetPackageType();
+#ifdef __LINUX__
+ package += GetAppDisplayBackend();
+#endif
+
wxString info;
info += "Version : " + wxString(SoftFever_VERSION) + "\n"
+ "Build : " + wxString(build_commit_label) + "\n"
- + "Package : " + GetPackageType() + "\n"
+ + "Package : " + package + "\n"
+ "Platform : " + GetOSinfo() + "\n"
+ "Processor : " + GetCPUinfo() + "\n"
+ "Memory : " + GetRAMinfo() + "RAM" + "\n"
@@ -751,6 +761,18 @@ wxString TroubleshootDialog::GetLinuxDisplayServer()
return "";
}
+
+wxString TroubleshootDialog::GetAppDisplayBackend()
+{
+ // Display backend actually used by the app (GDK), not the session type
+ wxString display;
+ switch (get_linux_display_backend()) {
+ case LinuxDisplayBackend::X11: display = "X11"; break;
+ case LinuxDisplayBackend::Wayland: display = "Wayland"; break;
+ default: break;
+ }
+ return display.IsEmpty() ? "" : (" (" + display + ")"); // spacing included here to prevent non centered text
+}
#endif
wxString TroubleshootDialog::GetPackageType()
diff --git a/src/slic3r/GUI/TroubleshootDialog.hpp b/src/slic3r/GUI/TroubleshootDialog.hpp
index f8b1e5fbaa..003e49b8f8 100644
--- a/src/slic3r/GUI/TroubleshootDialog.hpp
+++ b/src/slic3r/GUI/TroubleshootDialog.hpp
@@ -68,6 +68,7 @@ protected:
#elif defined(__LINUX__)
wxString GetLinuxDistroName();
wxString GetLinuxDisplayServer();
+ wxString GetAppDisplayBackend();
#endif
wxString GetPackageType();
wxString GetCPUinfo();
From 0f59cd5d0554e69af6818677b4af85c54946ead1 Mon Sep 17 00:00:00 2001
From: Kris Austin
Date: Sun, 18 Oct 2026 13:32:41 -0500
Subject: [PATCH 5/8] Let Global shortcuts run while typing, except keys that
edit text (#16558)
#16505 skipped every Global shortcut while ImGui had the keyboard, so
Ctrl+S, Ctrl+R and the others did nothing while the cursor was in the
text tool's box. Skip only the chords that edit text, and only while a
text field (a wx text entry, a spin control, a web view, or an active
ImGui InputText) has the focus. Skip every Global shortcut while an
ImGui modal is open on the focused canvas.
KeyChord::edits_text() defines the text-editing chords. The Global page
of the shortcuts dialog no longer says these shortcuts apply while
typing.
---
docs/HLSD/keyboard-shortcuts.md | 7 ++
src/slic3r/GUI/ImGuiWrapper.cpp | 22 +++++
src/slic3r/GUI/ImGuiWrapper.hpp | 7 ++
src/slic3r/GUI/KBShortcutsDialog.cpp | 9 +-
src/slic3r/GUI/KeyChord.cpp | 34 +++++++
src/slic3r/GUI/KeyChord.hpp | 3 +
src/slic3r/GUI/MainFrame.cpp | 44 +++++----
src/slic3r/GUI/OpenGLManager.cpp | 9 +-
src/slic3r/GUI/OpenGLManager.hpp | 2 +
tests/slic3rutils/test_imgui_wrapper.cpp | 108 ++++++++++++++++++++++-
tests/slic3rutils/test_shortcuts.cpp | 64 ++++++++++++++
11 files changed, 284 insertions(+), 25 deletions(-)
diff --git a/docs/HLSD/keyboard-shortcuts.md b/docs/HLSD/keyboard-shortcuts.md
index 7cfc72c30f..cf48a13c8d 100644
--- a/docs/HLSD/keyboard-shortcuts.md
+++ b/docs/HLSD/keyboard-shortcuts.md
@@ -53,6 +53,13 @@ Space counts as typing. The speed dial's default is the one bare Space, and
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
combo boxes), so it opens the dial from the canvases and the tab strip only.
+`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports
+while a text field has the focus (a wx text entry, spin control, web view or ImGui
+InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog
+would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack
+directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on
+a visible canvas from `OpenGLManager::create_wxglcanvas()`.
+
## Which event a chord matches
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp
index 9ce8d79e4d..080e85ad4e 100644
--- a/src/slic3r/GUI/ImGuiWrapper.cpp
+++ b/src/slic3r/GUI/ImGuiWrapper.cpp
@@ -62,6 +62,7 @@
#include "Search.hpp"
#include "BitmapCache.hpp"
#include "GUI_App.hpp"
+#include "KeyChord.hpp"
#include
#include
@@ -2134,6 +2135,27 @@ bool ImGuiWrapper::want_any_input() const
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
}
+bool ImGuiWrapper::text_input_active() const
+{
+ const ImGuiID id = ImGui::GetActiveID();
+ return id != 0 && ImGui::GetInputTextState(id) != nullptr;
+}
+
+bool ImGuiWrapper::modal_popup_open() const
+{
+ // A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished
+ // frame drew it, which NewFrame() moves from Active to WasActive.
+ const ImGuiWindow* modal = ImGui::GetTopMostPopupModal();
+ if (modal == nullptr)
+ return false;
+ return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active;
+}
+
+bool ImGuiWrapper::owns_key(const KeyChord& chord) const
+{
+ return modal_popup_open() || (chord.edits_text() && text_input_active());
+}
+
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
{
auto item = m_custom_glyph_rects_ids.find(tex_id);
diff --git a/src/slic3r/GUI/ImGuiWrapper.hpp b/src/slic3r/GUI/ImGuiWrapper.hpp
index 1a6f5e1bb9..6b5487bb5f 100644
--- a/src/slic3r/GUI/ImGuiWrapper.hpp
+++ b/src/slic3r/GUI/ImGuiWrapper.hpp
@@ -35,6 +35,7 @@ struct ImRect;
namespace Slic3r {
namespace GUI {
+struct KeyChord;
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
@@ -219,6 +220,12 @@ public:
bool want_keyboard() const;
bool want_text_input() const;
bool want_any_input() const;
+ // True while an ImGui InputText is active, without the one-frame lag of want_text_input().
+ bool text_input_active() const;
+ // True while an ImGui modal popup is on screen.
+ bool modal_popup_open() const;
+ // True for every key while a modal is open, and for text-editing keys while an InputText is active.
+ bool owns_key(const KeyChord& chord) const;
// Optional inputs are used for set up value inside of an optional, with default value
//
diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp
index 0fbe4f79c4..47fc0631ee 100644
--- a/src/slic3r/GUI/KBShortcutsDialog.cpp
+++ b/src/slic3r/GUI/KBShortcutsDialog.cpp
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
using Section = ShortcutSection;
if (wxGetApp().is_editor()) {
- page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, {
+ page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, {
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
@@ -438,17 +438,16 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut
SetBackgroundColour(*wxWHITE);
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
- // A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use.
+ // A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use.
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
const wxString advice = global_key_advice();
- const wxString typing = _L("Global shortcuts also apply while typing.");
const wxString rule = _L("A key that types a character cannot be a global shortcut.");
- m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms.");
+ m_hint = global ? advice : _L("Esc cancels, Enter confirms.");
m_rejection = rule + "\n" + advice;
// Wide enough for each sentence on a line of its own where the translation allows, within limits.
int width = FromDIP(450);
- for (const wxString& sentence : { typing, rule, advice }) {
+ for (const wxString& sentence : { rule, advice }) {
int extent = 0;
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
width = std::max(width, extent);
diff --git a/src/slic3r/GUI/KeyChord.cpp b/src/slic3r/GUI/KeyChord.cpp
index 768e503486..652eda2ae4 100644
--- a/src/slic3r/GUI/KeyChord.cpp
+++ b/src/slic3r/GUI/KeyChord.cpp
@@ -187,6 +187,40 @@ bool KeyChord::is_menu_accelerator() const
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
}
+bool KeyChord::edits_text() const
+{
+ if (!valid())
+ return false;
+ if (!is_menu_accelerator())
+ return true;
+ const int mods = modifiers & ~wxMOD_SHIFT;
+ switch (key) {
+ case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN:
+ case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE:
+#ifdef __APPLE__
+ return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word
+#else
+ return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
+#endif
+ case WXK_INSERT:
+ return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
+ case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN:
+ return mods == wxMOD_NONE;
+ default:
+ break;
+ }
+ if (!is_printable(key))
+ return false;
+#ifdef __APPLE__
+ if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL)
+ return true; // Option types a character, Control runs the Cocoa text key bindings
+#else
+ if (mods == (wxMOD_CONTROL | wxMOD_ALT))
+ return true; // AltGr types a character
+#endif
+ return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z');
+}
+
// Only a chord the desktop acts on while still delivering it to the app belongs here.
bool KeyChord::is_system_shortcut() const
{
diff --git a/src/slic3r/GUI/KeyChord.hpp b/src/slic3r/GUI/KeyChord.hpp
index 36d1ca8d16..f969cd149e 100644
--- a/src/slic3r/GUI/KeyChord.hpp
+++ b/src/slic3r/GUI/KeyChord.hpp
@@ -35,6 +35,9 @@ struct KeyChord
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
// swallowing typing in text fields.
bool is_menu_accelerator() const;
+ // True for a chord a focused text field acts on: typing a character (AltGr and macOS Option
+ // characters included), moving the caret, selecting, deleting, or the clipboard and undo chords.
+ bool edits_text() const;
// True for a chord the desktop acts on although the app receives it, so a binding would
// take it from the system.
bool is_system_shortcut() const;
diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp
index 66e982ad9f..b1ae832abc 100644
--- a/src/slic3r/GUI/MainFrame.cpp
+++ b/src/slic3r/GUI/MainFrame.cpp
@@ -50,6 +50,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -107,6 +108,8 @@
#include "../Utils/PrintHost.hpp"
#include "GUI_App.hpp"
+#include "ImGuiWrapper.hpp"
+#include "OpenGLManager.hpp"
#include "Shortcuts.hpp"
#include "UnsavedChangesDialog.hpp"
#include "PublishSettingsDialog.hpp"
@@ -184,18 +187,31 @@ enum class ERescaleTarget
namespace {
+bool focus_is_text_field(wxWindow* focus)
+{
+ if (dynamic_cast(focus) || dynamic_cast(focus))
+ return true; // a web page can have its own text fields
+ if (dynamic_cast(focus) || dynamic_cast(focus))
+ return true; // the native Windows spin control is not a wxTextEntry
+ return false;
+}
+
+// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch.
+const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus)
+{
+ if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen())
+ return nullptr;
+ return wxGetApp().imgui();
+}
+
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
-// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has
-// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and
-// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page.
+// controls, so only take Space when the focused window has no keyboard-activation meaning of its
+// own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook
+// itself does too, so Space still opens the dial on any page.
bool focus_keeps_space(wxWindow* focus)
{
if (!focus)
return false;
- if (dynamic_cast(focus))
- return true; // typing a space into a text field
- if (dynamic_cast(focus))
- return true; // web content scrolls and hosts its own text fields
if (dynamic_cast<::Button*>(focus))
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
if (dynamic_cast(focus))
@@ -816,16 +832,12 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
bool MainFrame::handle_global_shortcut(const KeyChord& chord)
{
- // ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so
- // while one of them is typed into the focused wx window is that canvas or an in-canvas frame:
- // neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord
- // here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw
- // the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave
- // the key to it then. A focused wxControl (text field, list, web view) is untouched: those
- // keep their own keys, and ImGui's flag can outlive the focus moving to one of them.
+ // CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and
+ // the keys ImGui owns to the focused canvas.
wxWindow* const focus = wxWindow::FindFocus();
- ImGuiWrapper* const imgui = wxGetApp().imgui();
- if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast(focus) == nullptr)
+ if (chord.edits_text() && focus_is_text_field(focus))
+ return false;
+ if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord))
return false;
const std::optional shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
diff --git a/src/slic3r/GUI/OpenGLManager.cpp b/src/slic3r/GUI/OpenGLManager.cpp
index 971a5282fc..fccfb57a9a 100644
--- a/src/slic3r/GUI/OpenGLManager.cpp
+++ b/src/slic3r/GUI/OpenGLManager.cpp
@@ -427,6 +427,8 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pairSetBackgroundStyle(wxBG_STYLE_PAINT);
return canvas;
}
+bool OpenGLManager::is_scene_canvas(const wxWindow* window)
+{
+ return window != nullptr && window->GetName() == SCENE_CANVAS_NAME;
+}
+
void OpenGLManager::detect_multisample(int* attribList)
{
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
diff --git a/src/slic3r/GUI/OpenGLManager.hpp b/src/slic3r/GUI/OpenGLManager.hpp
index 9e82123205..e9de7fc197 100644
--- a/src/slic3r/GUI/OpenGLManager.hpp
+++ b/src/slic3r/GUI/OpenGLManager.hpp
@@ -101,6 +101,8 @@ public:
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
+ // True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into.
+ static bool is_scene_canvas(const wxWindow* window);
static const GLInfo& get_gl_info() { return s_gl_info; }
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
diff --git a/tests/slic3rutils/test_imgui_wrapper.cpp b/tests/slic3rutils/test_imgui_wrapper.cpp
index f24e371cd2..544a3832f5 100644
--- a/tests/slic3rutils/test_imgui_wrapper.cpp
+++ b/tests/slic3rutils/test_imgui_wrapper.cpp
@@ -1,6 +1,7 @@
-// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
-// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
-// ; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
+// How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key.
+// ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue
+// as test_texture_color_palette.cpp (wx pulls in ; keep WIN32_LEAN_AND_MEAN / NOMINMAX
+// ahead of it).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
@@ -17,12 +18,15 @@
#include
#include
+#include
#include
#include "slic3r/GUI/ImGuiWrapper.hpp"
+#include "slic3r/GUI/KeyChord.hpp"
using Catch::Matchers::WithinAbs;
using Slic3r::GUI::ImGuiWrapper;
+using Slic3r::GUI::KeyChord;
namespace {
@@ -41,6 +45,45 @@ float wheel_after(int rotation, wxMouseWheelAxis axis)
return ImGui::GetIO().MouseWheel;
}
+// Runs ImGui frames without OpenGL by building the font atlas on the CPU.
+struct ImGuiFrames
+{
+ ImGuiWrapper imgui;
+
+ ImGuiFrames()
+ {
+ imgui.set_display_size(800.f, 600.f);
+ unsigned char* pixels = nullptr;
+ int width = 0, height = 0;
+ ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
+ }
+
+ template void frame(Draw draw)
+ {
+ ImGui::NewFrame();
+ draw();
+ ImGui::EndFrame();
+ }
+
+ // SetKeyboardFocusHere() activates the field in the next frame that draws it.
+ void draw_input(bool focus)
+ {
+ ImGui::Begin("window");
+ if (focus)
+ ImGui::SetKeyboardFocusHere();
+ ImGui::InputText("##text", text, sizeof(text));
+ ImGui::End();
+ }
+
+ char text[16] = "";
+};
+
+void draw_modal()
+{
+ if (ImGui::BeginPopupModal("modal"))
+ ImGui::EndPopup();
+}
+
} // namespace
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
@@ -59,3 +102,62 @@ TEST_CASE("a wheel event with no rotation or on the horizontal axis does not ste
}));
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
}
+
+TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]")
+{
+ ImGuiFrames f;
+ f.frame([&] { f.draw_input(false); });
+ CHECK_FALSE(f.imgui.text_input_active());
+
+ f.frame([&] { f.draw_input(true); });
+ f.frame([&] { f.draw_input(false); });
+ CHECK(f.imgui.text_input_active());
+
+ f.frame([] {});
+ f.frame([] {});
+ CHECK_FALSE(f.imgui.text_input_active());
+}
+
+TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]")
+{
+ ImGuiFrames f;
+ CHECK_FALSE(f.imgui.modal_popup_open());
+
+ f.frame([] {
+ ImGui::OpenPopup("modal");
+ draw_modal();
+ });
+ CHECK(f.imgui.modal_popup_open());
+
+ // A frame opened by input, before anything is drawn in it.
+ ImGui::NewFrame();
+ CHECK(f.imgui.modal_popup_open());
+ draw_modal();
+ ImGui::EndFrame();
+ CHECK(f.imgui.modal_popup_open());
+
+ // A frame that no longer draws it, as when its canvas is hidden.
+ f.frame([] {});
+ REQUIRE(ImGui::GetTopMostPopupModal() != nullptr);
+ CHECK_FALSE(f.imgui.modal_popup_open());
+}
+
+TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]")
+{
+ ImGuiFrames f;
+ const KeyChord letter{ 'A' };
+ const KeyChord save{ 'S', wxMOD_CONTROL };
+ CHECK_FALSE(f.imgui.owns_key(letter));
+ CHECK_FALSE(f.imgui.owns_key(save));
+
+ f.frame([&] { f.draw_input(true); });
+ f.frame([&] { f.draw_input(false); });
+ CHECK(f.imgui.owns_key(letter));
+ CHECK_FALSE(f.imgui.owns_key(save));
+
+ f.frame([] {
+ ImGui::OpenPopup("modal");
+ draw_modal();
+ });
+ CHECK(f.imgui.owns_key(save));
+}
diff --git a/tests/slic3rutils/test_shortcuts.cpp b/tests/slic3rutils/test_shortcuts.cpp
index a93dd4c92d..d36c1d3b39 100644
--- a/tests/slic3rutils/test_shortcuts.cpp
+++ b/tests/slic3rutils/test_shortcuts.cpp
@@ -126,6 +126,55 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
}
+TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]")
+{
+ CHECK(KeyChord{ 'A' }.edits_text());
+ CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text());
+ CHECK(KeyChord{ WXK_SPACE }.edits_text());
+ CHECK(KeyChord{ WXK_BACK }.edits_text());
+ CHECK(KeyChord{ WXK_DELETE }.edits_text());
+ CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text());
+ CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text());
+ CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
+ CHECK(KeyChord{ WXK_HOME }.edits_text());
+ CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text());
+ CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text());
+ CHECK(KeyChord{ WXK_INSERT }.edits_text());
+ CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text());
+ CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text());
+ CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text());
+ CHECK(KeyChord{ WXK_RETURN }.edits_text());
+ CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text());
+ CHECK(KeyChord{ WXK_ESCAPE }.edits_text());
+ CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text());
+ for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' })
+ CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text());
+ CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
+
+ CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text());
+ CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text());
+ CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text());
+ CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text());
+ CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text());
+ CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
+ CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text());
+ CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text());
+ CHECK_FALSE(KeyChord{}.edits_text());
+#ifdef __APPLE__
+ CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text());
+ CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text());
+ CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
+ CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text());
+ CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text());
+ CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
+#else
+ CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
+ CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text());
+ CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
+ CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text());
+#endif
+}
+
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
{
#ifdef _WIN32
@@ -186,6 +235,21 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
}
}
+TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]")
+{
+ ShortcutRegistry registry;
+ size_t checked = 0;
+ for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
+ const ShortcutInfo& info = shortcut_info(Shortcut(i));
+ if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial)
+ continue;
+ CAPTURE(info.key);
+ CHECK_FALSE(registry.binding(info.id).edits_text());
+ ++checked;
+ }
+ CHECK(checked > 0);
+}
+
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
{
ShortcutRegistry registry;
From 06665d05582f29acc84cb63cfeb1f719a7f904fd Mon Sep 17 00:00:00 2001
From: Kris Austin
Date: Sun, 18 Oct 2026 13:33:53 -0500
Subject: [PATCH 6/8] Post a readable diff of changed custom G-code on profile
PRs (#16605)
A profile stores each custom G-code setting as one JSON string, so GitHub
shows a G-code edit as one long changed line and reviewers cannot see
what changed.
Add a gcode-diff command to orca_profile_tool.py. For each changed
profile file it decodes, on both sides, every *_gcode and multiline
string option PrintConfig.cpp defines, the options Tab.cpp edits as
code, and any string value that spans several lines. It prints one
collapsed unified diff per setting as markdown. Files with the same diff
for a setting share one section. Trailing spaces are removed, file names
cannot mention GitHub users, and the output is cut to whole sections to
fit a GitHub comment.
Check profiles runs it between the PR's merge commit and its first
parent and adds the result to its artifact, with a line naming the PR
commit. Post profile check comment posts it under its own marker. It
keeps the comment while the diff is unchanged, edits it in place when
only the commit changed, replaces it when the diff changes, and deletes
it when the PR no longer changes any G-code.
The posting job still runs no PR code. It now posts only when the run
checked the head of the PR the artifact names, which covers the
validation comment too.
---
.github/workflows/check_profiles.yml | 25 ++
.github/workflows/check_profiles_comment.yml | 61 +++-
scripts/orca_profile_tool.py | 255 ++++++++++++++-
scripts/tests/test_profile_tool.py | 312 ++++++++++++++++++-
4 files changed, 643 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/check_profiles.yml b/.github/workflows/check_profiles.yml
index 4167cc705b..0b29732fc8 100644
--- a/.github/workflows/check_profiles.yml
+++ b/.github/workflows/check_profiles.yml
@@ -232,6 +232,31 @@ jobs:
mkdir -p ${{ runner.temp }}/profile-check-results
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
+ # Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
+ # one long line per setting, for check_profiles_comment.yml to post.
+ - name: Diff custom G-code
+ if: ${{ always() && github.event_name == 'pull_request' }}
+ continue-on-error: true
+ env:
+ PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
+ results=${{ runner.temp }}/profile-check-results
+ diff=${{ runner.temp }}/gcode_diff.md
+ # HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
+ python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
+ if [ -s "$diff" ]; then
+ {
+ echo ""
+ cat "$diff"
+ echo ""
+ # The comment workflow finds this line as the only one starting with .
+ echo "For commit ${PR_HEAD_SHA} of this PR."
+ } > "$results/gcode_diff.md"
+ fi
+ # Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
+ touch "$results/gcode_diff_done.txt"
+
- name: Prepare comment artifact
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
run: |
diff --git a/.github/workflows/check_profiles_comment.yml b/.github/workflows/check_profiles_comment.yml
index 21098cc467..bd42b8622d 100644
--- a/.github/workflows/check_profiles_comment.yml
+++ b/.github/workflows/check_profiles_comment.yml
@@ -38,6 +38,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
if [ ! -f pr_number.txt ]; then
echo "No pr_number.txt in artifact, skipping."
@@ -49,18 +50,72 @@ jobs:
echo "Invalid PR number: $PR_NUMBER"
exit 1
fi
+ # PR code wrote the artifact, so only a run for the named PR's current head may post.
+ if [ -z "$HEAD_SHA" ]; then
+ echo "The triggering run names no head commit."
+ exit 1
+ fi
+ if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
+ echo "Cannot look up PR #${PR_NUMBER}."
+ exit 1
+ fi
+ if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
+ echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
+ exit 0
+ fi
+
+ # Lists every bot comment on the PR, one JSON object per line.
+ COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
+ --jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
- OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
- --jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
+ OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
for comment_id in $OLD_IDS; do
echo "Deleting outdated profile-validation comment ${comment_id}"
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
done
# Post a new comment only when validation failed (pr_comment.md present).
+ # A failed post fails the job only after the G-code diff is handled.
+ failed=0
if [ -f pr_comment.md ]; then
- gh pr comment "$PR_NUMBER" --body-file pr_comment.md
+ gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
else
echo "Validation succeeded; cleaned up prior comments without posting."
fi
+
+ # Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
+ update_gcode_diff_comment() {
+ if [ ! -f gcode_diff_done.txt ]; then
+ echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
+ return 0
+ fi
+ GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("")) | .id')
+ if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
+ echo "gcode_diff.md is over GitHub's comment limit; not posting it."
+ rm gcode_diff.md
+ fi
+ if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
+ OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
+ NEW_BODY=$(tr -d '\r' < gcode_diff.md)
+ if [ "$OLD_BODY" = "$NEW_BODY" ]; then
+ echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
+ return 0
+ fi
+ # Edits in place when only the line differs, since edits notify nobody.
+ if [ "$(grep -v '^' <<< "$OLD_BODY")" = "$(grep -v '^' <<< "$NEW_BODY")" ]; then
+ echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
+ gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
+ return 0
+ fi
+ fi
+ for comment_id in $GCODE_IDS; do
+ echo "Deleting outdated G-code diff comment ${comment_id}"
+ gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
+ done
+ if [ -f gcode_diff.md ]; then
+ gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
+ fi
+ }
+ update_gcode_diff_comment
+ exit "$failed"
diff --git a/scripts/orca_profile_tool.py b/scripts/orca_profile_tool.py
index f1a428cdfe..2519ce9e43 100755
--- a/scripts/orca_profile_tool.py
+++ b/scripts/orca_profile_tool.py
@@ -12,6 +12,7 @@ commands:
trim delete profile files no .json list references
update-index regenerate the *_list sections of .json
legacy-compat write the printers user presets can be migrated back to
+ gcode-diff print a readable markdown diff of changed custom G-code
options shared by several commands:
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
@@ -94,11 +95,14 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
"""
import argparse
+import difflib
import functools
+import html
import json
import os
import posixpath
import re
+import subprocess
import sys
import uuid
from collections import Counter, defaultdict
@@ -192,6 +196,7 @@ VECTOR_KEYS = {
PRINT_CONFIG_CPP = os.path.normpath(
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
+TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
@functools.lru_cache(maxsize=None)
@@ -313,11 +318,11 @@ REMEDY_HINTS = {
}
-def print_error(msg):
- print(f"\033[91m[ERROR]\033[0m {msg}") # Red
+def print_error(msg, file=None):
+ print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
-def print_warning(msg):
- print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
+def print_warning(msg, file=None):
+ print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
def print_info(msg):
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
@@ -3209,6 +3214,223 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
return 0
+# ---------------------------------------------------------------------------
+# gcode-diff
+# ---------------------------------------------------------------------------
+
+# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
+GCODE_DIFF_BUDGET = 60000
+# The file names a section's summary shows before "and N more".
+GCODE_DIFF_SUMMARY_FILES = 3
+
+GCODE_DIFF_HEADER = (
+ "## Custom G-code changes\n"
+ "\n"
+ "GitHub shows custom G-code in a profile as one long line, so below is a line by line "
+ "diff of each changed G-code or other text setting.\n")
+
+
+@functools.lru_cache(maxsize=None)
+def _text_option_keys():
+ """The string options PrintConfig.cpp defines as G-code or other multi-line text
+ (every *_gcode option, and every one it marks multiline, such as notes), plus the
+ ones Tab.cpp edits as code, such as filename_format."""
+ with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
+ source = f.read()
+ keys = set()
+ for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
+ match = re.match(r'"(\w+)",\s*coStrings?\b', body)
+ if match and (match.group(1).endswith("_gcode")
+ or re.search(r"def->multiline\s*=\s*true", body)):
+ keys.add(match.group(1))
+ if "machine_start_gcode" not in keys:
+ raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
+ # Tab.cpp only adds keys, so a missing file is not an error.
+ try:
+ with open(TAB_CPP, encoding="utf-8") as f:
+ gui = f.read()
+ except OSError:
+ gui = ""
+ keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
+ gui, re.DOTALL))
+ return frozenset(keys)
+
+
+def _git(cwd, *args, stdin=None):
+ return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
+ capture_output=True).stdout
+
+
+def _blobs_at(profiles_dir, specs):
+ """{(ref, path): bytes, or None if not a file there} for (ref, path relative to
+ profiles_dir) pairs, all read by one git cat-file process."""
+ prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
+ out = _git(profiles_dir, "cat-file", "--batch",
+ stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
+ blobs, pos = {}, 0
+ for spec in specs:
+ end = out.index(b"\n", pos)
+ header = out[pos:end]
+ pos = end + 1
+ # " missing" and " ambiguous" have no content; " " does.
+ if header.endswith((b" missing", b" ambiguous")):
+ blobs[spec] = None
+ continue
+ _oid, kind, size = header.split()
+ blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
+ pos += int(size) + 1
+ return blobs
+
+
+def _parse_profile(raw, rel, where):
+ """The JSON in raw, or None with a warning when it is absent or not JSON."""
+ if raw is None:
+ print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
+ return None
+ try:
+ return json.loads(raw.decode("utf-8-sig"))
+ except ValueError as e:
+ print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
+ return None
+
+
+def _changed_profiles(profiles_dir, base, head):
+ """(old path, new path) of each JSON file under profiles_dir that differs between
+ base and head (the working tree, untracked files included, when None); the absent
+ side of an added or deleted file is None, and so is a side that is not JSON. Renames
+ are paired."""
+ out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
+ *([head] if head else []), "--", ".")
+ fields = out.decode("utf-8").split("\0")
+ pairs, i = [], 0
+ while i + 1 < len(fields):
+ status = fields[i]
+ old = new = fields[i + 1]
+ if status[0] in "RC":
+ new = fields[i + 2]
+ i += 3
+ else:
+ i += 2
+ old = None if status[0] == "A" or not old.endswith(".json") else old
+ new = None if status[0] == "D" or not new.endswith(".json") else new
+ if old or new:
+ pairs.append((old, new))
+ if head is None:
+ out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
+ pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
+ if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
+ return pairs
+
+
+def _text_lines(value):
+ return [line.rstrip() for line in value.split("\n")] if value else []
+
+
+def _text_changes(old, new):
+ """(setting, unified diff) for every G-code or other text value that differs
+ between two profiles; a vector option with several values gives one per value."""
+ changes = []
+ for key in list(new) + [k for k in old if k not in new]:
+ a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
+ if a == b or not all(isinstance(v, str) for v in a + b):
+ continue
+ if key not in _text_option_keys() and not any("\n" in v for v in a + b):
+ continue
+ width = max(len(a), len(b))
+ for i in range(width):
+ diff = list(difflib.unified_diff(
+ _text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
+ lineterm=""))[2:] # drops the ---/+++ file header
+ if diff:
+ changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
+ return changes
+
+
+def _gcode_diff_section(files, setting, diff):
+ # A fence longer than any backtick run in the G-code cannot be closed by it.
+ fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
+ # In , the "@BBL" of a filament file name does not mention a GitHub user.
+ def code(text):
+ return f"{html.escape(text, quote=False)}"
+ names, listing = ", ".join(map(code, files)), ""
+ if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
+ listing = f"{names}
\n\n" # an HTML block, so markdown in names stays literal
+ names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
+ + f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
+ return (f"\n\n{names} - {code(setting)} (click to expand)"
+ f"
\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n \n")
+
+
+def gcode_diff(profiles_dir, base, head=None, vendors=None):
+ """Markdown with one collapsed diff per changed G-code setting between base and
+ head, or "" when none changed. Files whose diff of a setting is identical share
+ one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
+ pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
+ if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
+ specs = ([(base, old) for old, _ in pairs if old]
+ + [(head, new) for _, new in pairs if new and head])
+ blobs = _blobs_at(profiles_dir, specs) if specs else {}
+
+ def load(ref, rel):
+ if rel is None:
+ return {}
+ if ref:
+ return _parse_profile(blobs[(ref, rel)], rel, ref)
+ try:
+ return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
+ except OSError as e:
+ print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
+ return None
+
+ groups = {} # (setting, diff) -> files, in the order git lists them
+ for old_rel, new_rel in pairs:
+ old, new = load(base, old_rel), load(head, new_rel)
+ if not isinstance(old, dict) or not isinstance(new, dict):
+ continue
+ for setting, diff in _text_changes(old, new):
+ groups.setdefault((setting, diff), []).append(new_rel or old_rel)
+ if not groups:
+ return ""
+
+ sections = [_gcode_diff_section(files, setting, diff)
+ for (setting, diff), files in groups.items()]
+ budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
+ shown = 0
+ for section in sections:
+ budget -= len(section.encode("utf-8"))
+ if budget < 0:
+ break
+ shown += 1
+ note, hidden = "", len(sections) - shown
+ if hidden:
+ note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
+ "shown because the comment would be too long for GitHub. See the Files "
+ "changed tab for the rest.\n")
+ return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
+
+
+def _default_gcode_diff_base(profiles_dir, head):
+ for main in ("upstream/main", "origin/main"):
+ try:
+ return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
+ except subprocess.CalledProcessError:
+ continue
+ raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
+
+
+def run_gcode_diff(profiles_dir, vendors, base, head):
+ try:
+ markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
+ head, vendors)
+ # UnicodeDecodeError means git listed a file name that is not UTF-8.
+ except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
+ stderr = getattr(e, "stderr", None)
+ print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
+ return 1
+ sys.stdout.write(markdown)
+ return 0
+
+
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@@ -3233,6 +3455,9 @@ examples:
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
+ orca_profile_tool.py gcode-diff > gcode.md
+ the changed custom G-code of the working tree since it left upstream/main,
+ as markdown with one collapsed diff per setting
after adding, renaming or deleting profile files, run in this order:
normalize -> update-index -> generate-id -> check
@@ -3387,6 +3612,25 @@ def build_parser():
legacy_cmd.add_argument("--check", action="store_true",
help="fail if the map differs from what --base implies; write nothing")
+ gcode_cmd = add(
+ "gcode-diff", [vendor_opt, profiles_opt],
+ "print a readable markdown diff of changed custom G-code",
+ "Print, as markdown, a unified diff of every G-code or other multi-line\n"
+ "setting that differs between --base and --head in each changed profile\n"
+ "file, one collapsed section per setting. Files whose diff of a setting is\n"
+ "identical share a section. Trailing spaces are removed from every line, and\n"
+ "the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
+ "when no such setting changed. CI posts it on profile PRs.\n"
+ "\n"
+ "The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
+ "defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
+ "string value that spans several lines.")
+ gcode_cmd.add_argument("--base", metavar="REF",
+ help="compare against this git ref (default: the merge base "
+ "with upstream/main, else origin/main)")
+ gcode_cmd.add_argument("--head", metavar="REF",
+ help="compare this git ref (default: the working tree)")
+
return parser
@@ -3423,6 +3667,9 @@ def main(argv=None):
if args.command == "legacy-compat":
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
+ if args.command == "gcode-diff":
+ return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
+
if args.command == "fix-variant":
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
strict=args.strict)
diff --git a/scripts/tests/test_profile_tool.py b/scripts/tests/test_profile_tool.py
index 167951d609..ed329b478c 100644
--- a/scripts/tests/test_profile_tool.py
+++ b/scripts/tests/test_profile_tool.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
-normalize, trim, update-index, fix-variant and check commands, and the subcommand
-dispatch that reaches them (stdlib unittest, no external deps).
+normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
+subcommand dispatch that reaches them (stdlib unittest, no external deps).
The id halves are covered by test_filament_id.py and test_setting_id.py.
@@ -14,9 +14,12 @@ import json
import os
import re
import shutil
+import stat
+import subprocess
import sys
import tempfile
import unittest
+from unittest import mock
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
@@ -41,7 +44,24 @@ class Tree:
os.makedirs(self.profiles)
def cleanup(self):
- shutil.rmtree(self.dir, ignore_errors=True)
+ root = os.path.abspath(self.dir)
+
+ def writable_retry(func, path, _exc):
+ # Windows refuses to delete read-only files such as git's objects, and POSIX
+ # refuses to delete the entries of a read-only directory.
+ if not os.path.lexists(path): # already removed, as on a second cleanup
+ return
+ parent = os.path.dirname(os.path.abspath(path))
+ try:
+ if os.path.commonpath([parent, root]) == root: # never the temp dir itself
+ os.chmod(parent, stat.S_IRWXU)
+ if not os.path.islink(path): # chmod would follow it out of the tree
+ os.chmod(path, stat.S_IRWXU)
+ func(path)
+ except (OSError, ValueError): # commonpath raises ValueError across drives
+ pass
+ hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
+ shutil.rmtree(self.dir, **{hook: writable_retry})
def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json")
@@ -1409,6 +1429,292 @@ class TestDispatch(TreeCase):
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
+# ---------------------------------------------------------------------------
+# gcode-diff
+# ---------------------------------------------------------------------------
+
+@unittest.skipUnless(shutil.which("git"), "git not installed")
+class TestGcodeDiff(TreeCase):
+ START = "G28\nM104 S200\nG1 Z5"
+
+ def git(self, *args):
+ subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
+ "-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
+
+ def printer(self, name, **extra):
+ self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
+
+ def commit(self):
+ self.git("add", "-A")
+ self.git("commit", "-q", "--allow-empty", "-m", "x")
+
+ @classmethod
+ def setUpClass(cls):
+ # git's list of the variables that tie a process to a repository, asked with no
+ # GIT_* set since a stray GIT_DIR fails the query.
+ clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
+ cls.repo_env_vars = set(subprocess.run(
+ ["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
+ capture_output=True, text=True).stdout.split())
+
+ def setUp(self):
+ super().setUp()
+ # A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
+ # tool's included, at the real repository instead of the temp tree.
+ env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
+ patcher = mock.patch.dict(os.environ, env, clear=True)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ self.git("init", "-q")
+ for name in ("A", "B", "C"):
+ self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
+ self.printer("Gone", machine_end_gcode="M84")
+ self.commit()
+
+ def diff(self):
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD")
+ self.assertEqual(rc, 0, out)
+ return out
+
+ def test_identical_edits_share_one_section(self):
+ for name in ("A", "B"):
+ self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
+ self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
+ out = self.diff()
+ self.assertEqual(out.count(""), 2)
+ self.assertIn("V/machine/A.json, V/machine/B.json - "
+ "machine_start_gcode", out)
+ self.assertIn("V/machine/C.json - machine_start_gcode", out)
+ # Trailing spaces are removed.
+ self.assertIn("\n-M104 S200\n+M104 S210\n", out)
+ self.assertIn("\n\n```diff\n@@ ", out)
+ self.assertIn("\n```\n\n ", out)
+
+ def test_nothing_is_printed_without_a_text_change(self):
+ self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
+ self.assertEqual(self.diff(), "")
+
+ def test_added_and_deleted_files_show_the_whole_value(self):
+ # An untracked file counts as added; the validator's user dir is not a profile.
+ self.printer("New", machine_start_gcode="G28\nG29")
+ self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
+ os.remove(self.t.path("V", "machine/Gone.json"))
+ out = self.diff()
+ self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
+ self.assertIn("V/machine/Gone.json - machine_end_gcode", out)
+ self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
+ self.assertNotIn("U.json", out)
+
+ def test_a_multi_line_value_counts_whatever_its_key(self):
+ self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
+ self.assertIn("V/machine/A.json - custom_note", self.diff())
+
+ def test_a_file_name_cannot_mention_a_user(self):
+ self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
+ self.assertIn("V/filament/PLA @BBL A1.json", self.diff())
+
+ def test_head_compares_two_commits(self):
+ self.printer("A", machine_start_gcode="G28")
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("V/machine/A.json - machine_start_gcode", out)
+
+ def test_a_renamed_file_diffs_against_its_old_self(self):
+ os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
+ self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("V/machine/A2.json - machine_start_gcode", out)
+ self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
+
+ def test_a_file_renamed_off_json_loses_its_values(self):
+ os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("V/machine/Gone.json - machine_end_gcode", out)
+ self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
+
+ def test_a_file_renamed_onto_json_is_new(self):
+ with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
+ json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
+ self.commit()
+ os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
+
+ def test_a_batch_read_survives_paths_that_are_not_files(self):
+ blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
+ ("HEAD", "V/machine/Gone.json")])
+ self.assertIsNone(blobs[("HEAD", "V/machine")])
+ self.assertIsNone(blobs[("HEAD", "V/no such.json")])
+ self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
+ "M84")
+
+ def test_each_value_of_a_vector_option_is_its_own_setting(self):
+ self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
+ self.commit()
+ self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
+ out = self.diff()
+ self.assertEqual(out.count(""), 1)
+ self.assertIn("filament_start_gcode[1]", out)
+
+ def test_vendor_limits_the_files(self):
+ self.printer("A", machine_start_gcode="G28")
+ self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("V/machine/A.json", out)
+ self.assertNotIn("W/machine/X.json", out)
+
+ def test_vendor_includes_files_moved_out_of_it(self):
+ self.t.add_vendor("W")
+ os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
+ data = self.t.read("W", "machine/A.json")
+ self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
+ self.commit()
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
+ "--vendor", "V")
+ self.assertEqual(rc, 0, out)
+ self.assertIn("W/machine/A.json - machine_start_gcode", out)
+
+ def same_edit(self, names):
+ """The diff after one start G-code edit to each of names; returns (summary, output)."""
+ for name in names:
+ self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
+ self.commit()
+ for name in names:
+ self.printer(name, machine_start_gcode="G28")
+ out = self.diff()
+ return re.search(r"(.*)
", out).group(1), out
+
+ def test_a_long_file_list_moves_into_the_section(self):
+ summary, out = self.same_edit("ABCDE")
+ self.assertIn("V/machine/C.json and 2 more - ", summary)
+ self.assertNotIn("D.json", summary)
+ self.assertIn("V/machine/D.json, V/machine/E.json
\n\n```diff",
+ out)
+
+ def test_four_files_are_all_named_in_the_summary(self):
+ summary, _out = self.same_edit("ABCD")
+ self.assertIn("V/machine/D.json - ", summary)
+ self.assertNotIn("more", summary)
+
+ def test_an_error_goes_to_stderr_only(self):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
+ self.assertEqual(rc, 1)
+ self.assertEqual(out, "")
+ self.assertIn("no-such-ref", err.getvalue())
+
+ def test_a_file_name_that_is_not_utf8_is_an_error(self):
+ bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
+ err = io.StringIO()
+ with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
+ contextlib.redirect_stderr(err):
+ rc, out = self.run_command("gcode-diff", "--base", "HEAD")
+ self.assertEqual((rc, out), (1, ""))
+ self.assertIn("invalid continuation byte", err.getvalue())
+
+ def test_output_is_cut_to_whole_sections(self):
+ for name in ("A", "B", "C"):
+ self.printer(name, machine_start_gcode=name * 300)
+ size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
+ with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
+ out = self.diff()
+ self.assertEqual(out.count(""), 2)
+ self.assertEqual(out.count(" "), 2)
+ self.assertIn("\n1 of 3 sections is not shown", out)
+ with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
+ self.assertIn("\n3 of 3 sections are not shown", self.diff())
+
+ def test_cleanup_removes_the_repo(self):
+ # setUp's commit leaves read-only git objects, and a read-only directory blocks
+ # deletion on POSIX.
+ os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
+ self.t.cleanup()
+ self.assertFalse(os.path.exists(self.t.dir))
+ with mock.patch.object(os, "chmod") as chmod:
+ self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
+ chmod.assert_not_called()
+
+ def test_only_repository_variables_are_stripped(self):
+ self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
+ self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
+
+ def test_cleanup_leaves_symlink_targets_alone(self):
+ outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
+ self.addCleanup(shutil.rmtree, outside, True)
+ target = os.path.join(outside, "f")
+ open(target, "w").close()
+ os.chmod(target, stat.S_IRUSR)
+ self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
+ machine = self.t.path("V", "machine")
+ try:
+ os.symlink(target, os.path.join(machine, "link"))
+ except (OSError, NotImplementedError):
+ self.skipTest("cannot create symlinks here")
+ os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
+ self.t.cleanup()
+ self.assertFalse(os.path.exists(self.t.dir))
+ self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
+
+ def test_a_git_hook_environment_is_ignored(self):
+ scripts = os.path.join(REPO_ROOT, "scripts")
+ result = subprocess.run(
+ [sys.executable, "-m", "unittest",
+ "tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
+ cwd=scripts, capture_output=True, text=True,
+ env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_the_options_are_read_from_the_engine(self):
+ keys = apt._text_option_keys()
+ self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
+ "notes", "post_process"} <= keys)
+ self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
+
+ START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
+
+ def keys_from(self, print_config, tab=None):
+ """_text_option_keys read from these sources; no tab means no Tab.cpp."""
+ paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
+ for path, text in zip(paths, (print_config, tab)):
+ if text is not None:
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(text)
+ apt._text_option_keys.cache_clear()
+ self.addCleanup(apt._text_option_keys.cache_clear)
+ with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
+ mock.patch.object(apt, "TAB_CPP", paths[1]):
+ return apt._text_option_keys()
+
+ def test_multiline_is_read_whatever_its_spacing(self):
+ keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
+ ' def->multiline = true;\n')
+ self.assertIn("printer_notes", keys)
+
+ def test_the_code_options_of_tab_cpp_are_read(self):
+ keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
+ 'option.opt.is_code = true;\n'
+ 'option = optgroup->get_option("layer_height");\n')
+ self.assertIn("filename_format", keys)
+ self.assertNotIn("layer_height", keys)
+
+ def test_a_missing_tab_cpp_is_not_an_error(self):
+ self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
+
+ def test_markdown_in_a_file_name_stays_literal(self):
+ section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
+ "V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
+ self.assertIn("\nV/a *b*.json, ", section)
+
+
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------
From da4d766d467cb90274393523c8ba68dea74bb3c2 Mon Sep 17 00:00:00 2001
From: Kris Austin
Date: Sun, 18 Oct 2026 19:33:58 -0500
Subject: [PATCH 7/8] Fix Moonraker status stream hanging app exit and missing
dead printers (#16604)
The status stream read the websocket synchronously. Beast's
expires_after() only applies to async operations, so when a printer
stopped sending, ws.read() blocked forever, and stop_status_stream()
joined that thread when the app closed. On Windows the stuck process
also froze the next launch through the shared WebView2 browser.
Run the whole stream on one io_context owned by the stream thread, and
have stop_status_stream() post an abort to it. A stop on an open
websocket sends a close frame and waits up to 1 s for the answer.
Keep-alive pings detect a dead printer, and the stream reconnects until
stopped, with the backoff capped at 32 s. When a quiet printer still
answers pings, its last status is sent again. The stream starts under
connect_mutex after a staleness check, and a stop from the stream's own
callback no longer joins its own thread.
The Moonraker tests move into test_moonraker_printer_agent.cpp.
---
src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 280 +++++++--
src/slic3r/Utils/MoonrakerPrinterAgent.hpp | 22 +-
tests/slic3rutils/CMakeLists.txt | 2 +-
.../slic3rutils/test_moonraker_discovery.cpp | 51 --
.../test_moonraker_printer_agent.cpp | 580 ++++++++++++++++++
5 files changed, 820 insertions(+), 115 deletions(-)
delete mode 100644 tests/slic3rutils/test_moonraker_discovery.cpp
create mode 100644 tests/slic3rutils/test_moonraker_printer_agent.cpp
diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
index 81a25165a2..8494eb5cfd 100644
--- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
+++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
@@ -5,6 +5,7 @@
#include "bambu_networking.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp"
+#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/DeviceManager.hpp"
@@ -19,14 +20,19 @@
#include
#include
#include
+#include
#include
#include
+#include
+#include
#include
#include
#include
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -37,6 +43,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -120,6 +127,31 @@ std::string map_moonraker_state(std::string state)
return "IDLE";
}
+// Runs one asynchronous operation to completion, because synchronous Beast calls ignore timeouts and cannot be aborted
+template
+beast::error_code run_until_done(net::io_context& ioc, const std::atomic& stop, Initiate&& initiate)
+{
+ // An abort that runs before the operation starts finds nothing to cancel
+ if (stop.load()) {
+ return net::error::operation_aborted;
+ }
+ beast::error_code result;
+ bool done = false;
+ initiate([&](beast::error_code ec, auto&&...) {
+ result = ec;
+ done = true;
+ });
+ ioc.restart();
+ while (!done && ioc.run_one() != 0) {} // run() never returns while the websocket idle timer is armed
+ return done ? result : beast::error_code{net::error::operation_aborted};
+}
+
+uint64_t steady_now_ms()
+{
+ return static_cast(
+ std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count());
+}
+
} // namespace
namespace Slic3r {
@@ -1298,6 +1330,8 @@ bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
}
http.timeout_connect(5)
.timeout_max(10)
+ // Gives up once the status stream, the only caller, stops
+ .on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
.on_complete([&](std::string body, unsigned status) {
if (status == 200) {
response_body = body;
@@ -1468,19 +1502,47 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const
return;
}
+ std::lock_guard lock(ws_abort_mutex);
ws_stop.store(false);
- ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); });
+ ws_thread = std::thread(
+ [this, dev_id, base_url, api_key, timings = ws_timings]() { run_status_stream(dev_id, base_url, api_key, timings); });
}
void MoonrakerPrinterAgent::stop_status_stream()
{
- ws_stop.store(true);
- if (ws_thread.joinable()) {
- ws_thread.join();
+ std::thread stopped;
+ {
+ std::lock_guard lock(ws_abort_mutex);
+ ws_stop.store(true);
+ if (ws_abort) {
+ ws_abort();
+ }
+ // A callback on the stream thread can stop the stream, which then ends on its own and is joined later, outside connect_mutex
+ if (ws_thread.get_id() != std::this_thread::get_id()) {
+ stopped = std::move(ws_thread);
+ }
+ }
+ if (stopped.joinable()) {
+ stopped.join();
}
}
-void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key)
+void MoonrakerPrinterAgent::join_stopped_status_stream()
+{
+ std::thread stopped;
+ {
+ std::lock_guard lock(ws_abort_mutex);
+ // A running stream belongs to a newer connection, so only one that is already stopping is joined
+ if (ws_stop.load() && ws_thread.get_id() != std::this_thread::get_id()) {
+ stopped = std::move(ws_thread);
+ }
+ }
+ if (stopped.joinable()) {
+ stopped.join();
+ }
+}
+
+void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings)
{
WsEndpoint endpoint;
if (!parse_ws_endpoint(base_url, endpoint)) {
@@ -1492,25 +1554,108 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
return;
}
+ net::io_context ioc;
+ net::steady_timer backoff{ioc};
+ std::function abort_connection; // Set while a connection attempt runs
+ const auto run = [&](auto&& initiate) { return run_until_done(ioc, ws_stop, initiate); };
+
+ // Registered under the lock stop_status_stream() takes, so a stop either finds this abort or has already set ws_stop
+ {
+ std::lock_guard lock(ws_abort_mutex);
+ if (ws_stop.load()) {
+ return;
+ }
+ ws_abort = [&] {
+ net::post(ioc, [&] {
+ backoff.cancel();
+ if (abort_connection) {
+ abort_connection();
+ }
+ });
+ };
+ }
+ const ScopeGuard clear_abort([this] {
+ std::lock_guard lock(ws_abort_mutex);
+ ws_abort = nullptr;
+ });
+
// Reconnection logic
ws_reconnect_requested.store(false); // Reset reconnect flag
- int retry_count = 0;
- const int max_retries = 10;
- const int base_delay_ms = 1000;
+ int retry_count = 0;
- while (!ws_stop.load() && retry_count < max_retries) {
+ while (!ws_stop.load()) {
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
+ tcp::resolver resolver{ioc};
+ websocket::stream ws{ioc};
+ beast::tcp_stream& stream = beast::get_lowest_layer(ws);
+ net::steady_timer close_deadline{ioc};
+ bool closing = false;
+ bool deadline_pending = false;
+ auto last_message = std::chrono::steady_clock::now();
+ // Sends a close frame if the websocket is open, and drops the socket when the printer has not answered within timings.close
+ const auto close_politely = [&](websocket::close_code code) {
+ if (!ws.is_open() || closing) {
+ return;
+ }
+ closing = true;
+ deadline_pending = true;
+ close_deadline.expires_after(timings.close);
+ close_deadline.async_wait([&](beast::error_code ec) {
+ deadline_pending = false;
+ if (!ec) {
+ beast::error_code ignored;
+ stream.socket().close(ignored);
+ }
+ });
+ ws.async_close(code, [&](beast::error_code) {
+ closing = false;
+ close_deadline.cancel();
+ });
+ };
+ abort_connection = [&] {
+ // Beast reports a closing websocket as not open, and dropping its socket would cut off the close frame
+ if (closing) {
+ return;
+ }
+ if (ws.is_open()) {
+ close_politely(websocket::close_code::going_away);
+ } else {
+ beast::error_code ignored;
+ stream.socket().close(ignored);
+ }
+ };
+ const ScopeGuard clear_connection_abort([&] { abort_connection = nullptr; });
+
try {
- net::io_context ioc;
- tcp::resolver resolver{ioc};
- beast::tcp_stream stream{ioc};
+ beast::error_code connect_ec = net::error::host_not_found;
+ for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
+ beast::error_code ignored;
+ stream.socket().close(ignored); // A failed attempt leaves the socket open
+ stream.expires_after(std::chrono::seconds(5));
+ connect_ec = run([&](auto&& handler) { stream.async_connect(entry.endpoint(), handler); });
+ if (!connect_ec || ws_stop.load()) {
+ break;
+ }
+ }
+ if (connect_ec) {
+ throw beast::system_error{connect_ec};
+ }
+ // Clears the connect deadline, which would otherwise close the socket 5 s after connecting
+ stream.expires_never();
- stream.expires_after(std::chrono::seconds(10));
- auto const results = resolver.resolve(endpoint.host, endpoint.port);
- stream.connect(results);
-
- websocket::stream ws{std::move(stream)};
+ websocket::stream_base::timeout timeouts{};
+ timeouts.handshake_timeout = timings.handshake;
+ timeouts.idle_timeout = timings.idle;
+ timeouts.keep_alive_pings = true;
+ ws.set_option(timeouts);
+ // Beast pings on a fixed schedule, so the heartbeat also checks how long the printer has sent no message
+ ws.control_callback([&](websocket::frame_type kind, beast::string_view) {
+ if (kind == websocket::frame_type::pong && !closing &&
+ std::chrono::steady_clock::now() - last_message >= timings.heartbeat) {
+ send_heartbeat_if_due(dev_id, timings.heartbeat);
+ }
+ });
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
req.set(http::field::user_agent, "OrcaSlicer");
if (!api_key.empty()) {
@@ -1522,7 +1667,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
if (!endpoint.port.empty() && endpoint.port != "80") {
host_header += ":" + endpoint.port;
}
- ws.handshake(host_header, endpoint.target);
+ if (const auto ec = run([&](auto&& handler) { ws.async_handshake(host_header, endpoint.target, handler); })) {
+ throw beast::system_error{ec};
+ }
ws.text(true);
// Send client identification
@@ -1534,7 +1681,10 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
identify["params"]["type"] = "agent";
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
identify["id"] = 0;
- ws.write(net::buffer(identify.dump()));
+ const std::string identify_payload = identify.dump();
+ if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(identify_payload), handler); })) {
+ throw beast::system_error{ec};
+ }
std::set subscribe_objects = {"print_stats", "virtual_sdcard"};
std::set available_objects;
@@ -1586,28 +1736,17 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
}
subscribe["params"]["objects"] = std::move(objects);
subscribe["id"] = 1;
- ws.write(net::buffer(subscribe.dump()));
+ const std::string subscribe_payload = subscribe.dump();
+ if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(subscribe_payload), handler); })) {
+ throw beast::system_error{ec};
+ }
// Read loop
while (!ws_stop.load()) {
- ws.next_layer().expires_after(std::chrono::seconds(2));
beast::flat_buffer buffer;
- beast::error_code ec;
- ws.read(buffer, ec);
- if (ec == beast::error::timeout) {
- const auto now_ms = static_cast(
- std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count());
- const auto last_ms = ws_last_emit_ms.load();
- if (last_ms == 0 || now_ms - last_ms >= 10000) {
- nlohmann::json message;
- {
- std::lock_guard lock(payload_mutex);
- message = build_print_payload_locked();
- }
- dispatch_message(dev_id, message.dump());
- ws_last_emit_ms.store(now_ms);
- }
- continue;
+ const auto ec = run([&](auto&& handler) { ws.async_read(buffer, handler); });
+ if (ws_stop.load()) {
+ break;
}
if (ec == websocket::error::closed) {
connection_lost = true;
@@ -1618,6 +1757,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
connection_lost = true;
break;
}
+ last_message = std::chrono::steady_clock::now();
+ // A message shows the printer is answering, so the next drop starts the backoff from reconnect_delay
+ retry_count = 0;
handle_ws_message(dev_id, beast::buffers_to_string(buffer.data()));
// Check if handle_ws_message triggered reconnection request
if (ws_reconnect_requested.exchange(false)) {
@@ -1625,20 +1767,18 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
break;
}
}
-
- beast::error_code ec;
- ws.close(websocket::close_code::normal, ec);
-
- // Only reset retry count on clean shutdown (not connection_lost)
- if (!connection_lost && !ws_stop.load()) {
- retry_count = 0;
- }
-
} catch (const std::exception& e) {
- BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
+ if (!ws_stop.load()) {
+ BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
+ }
connection_lost = true;
}
+ close_politely(ws_stop.load() ? websocket::close_code::going_away : websocket::close_code::normal);
+ ioc.restart();
+ // Both handlers refer to this connection, so neither may be left queued for the next one
+ while ((closing || deadline_pending) && ioc.run_one() != 0) {}
+
// Exit loop on clean shutdown
if (!connection_lost) {
break;
@@ -1649,17 +1789,13 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
break;
}
- // Exponential backoff before reconnection
- int delay_ms = base_delay_ms * (1 << std::min(retry_count, 5));
- BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay_ms << "ms (attempt " << (retry_count + 1) << ")";
- std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
+ // Exponential backoff before reconnection, capped at 32 times reconnect_delay so a printer that comes back is found again
+ const auto delay = timings.reconnect_delay * (1 << std::min(retry_count, 5));
+ BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay.count() << "ms (attempt " << (retry_count + 1) << ")";
+ backoff.expires_after(delay);
+ run([&](auto&& handler) { backoff.async_wait(handler); });
retry_count++;
}
-
- if (retry_count >= max_retries) {
- BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Max reconnection attempts reached";
- dispatch_local_connect(ConnectStatusLost, dev_id, "max_retries");
- }
}
void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload)
@@ -1733,8 +1869,7 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
}
if (updated) {
- const auto now_ms = static_cast(
- std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count());
+ const auto now_ms = steady_now_ms();
const auto last_dispatch_ms = ws_last_dispatch_ms.load();
// Dispatch if: critical change OR throttle interval elapsed
@@ -1755,6 +1890,24 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
}
}
+void MoonrakerPrinterAgent::send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval)
+{
+ const auto now_ms = steady_now_ms();
+ const auto last_ms = ws_last_emit_ms.load();
+ // Re-sends only a status the printer has reported at least once
+ if (last_ms == 0 || now_ms - last_ms < static_cast(interval.count())) {
+ return;
+ }
+
+ nlohmann::json message;
+ {
+ std::lock_guard lock(payload_mutex);
+ message = build_print_payload_locked();
+ }
+ dispatch_message(dev_id, message.dump());
+ ws_last_emit_ms.store(now_ms);
+}
+
void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
{
if (!updates.is_object()) {
@@ -2139,8 +2292,15 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg;
}
- // Start WebSocket status stream
- start_status_stream(dev_id, base_url, api_key);
+ join_stopped_status_stream();
+ // Holds connect_mutex across the staleness check and the start, so a disconnect during the queries above prevents the start
+ {
+ std::lock_guard lock(connect_mutex);
+ if (is_stale()) {
+ return;
+ }
+ start_status_stream(dev_id, base_url, api_key);
+ }
#endif
// Success!
diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp
index 141574b84d..a7edc476e8 100644
--- a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp
+++ b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp
@@ -5,6 +5,7 @@
#include "bambu_networking.hpp"
#include
+#include
#include
#include
#include
@@ -85,6 +86,16 @@ protected:
bool use_ssl = false;
} device_info;
+ // Copied when the stream starts, so changes apply to the next start_status_stream()
+ struct StatusStreamTimings
+ {
+ std::chrono::milliseconds handshake{std::chrono::seconds(10)}; // Opening and closing handshakes
+ std::chrono::milliseconds close{std::chrono::seconds(1)}; // Wait for the close frame's answer before dropping the socket
+ std::chrono::milliseconds idle{std::chrono::seconds(30)}; // Silence before giving up, with a ping every half of it
+ std::chrono::milliseconds heartbeat{std::chrono::seconds(10)}; // Silence before the cached status is sent again
+ std::chrono::milliseconds reconnect_delay{std::chrono::seconds(1)}; // First wait, doubling per consecutive failure up to 32x
+ } ws_timings;
+
// Tray data for AMS payload building
struct AmsTrayData {
int slot_index = 0; // 0-based slot index
@@ -102,6 +113,8 @@ protected:
// Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
+ void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
+ void stop_status_stream();
// State access for derived classes
mutable std::recursive_mutex state_mutex;
@@ -133,10 +146,11 @@ private:
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id);
void dispatch_message(const std::string& dev_id, const std::string& payload);
- void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
- void stop_status_stream();
- void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
+ // Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
+ void join_stopped_status_stream();
+ void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
void handle_ws_message(const std::string& dev_id, const std::string& payload);
+ void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
@@ -196,6 +210,8 @@ private:
std::atomic ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic ws_last_emit_ms{0};
std::thread ws_thread;
+ std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
+ std::function ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
// Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt
index 83632601f7..c9285b955e 100644
--- a/tests/slic3rutils/CMakeLists.txt
+++ b/tests/slic3rutils/CMakeLists.txt
@@ -6,7 +6,7 @@ add_executable(${_TEST_NAME}_tests
test_creality_cfs_match.cpp
test_dev_mapping.cpp
test_device_access.cpp
- test_moonraker_discovery.cpp
+ test_moonraker_printer_agent.cpp
test_network_discovery.cpp
test_filament_bitmap_utils.cpp
test_colour_picker_field.cpp
diff --git a/tests/slic3rutils/test_moonraker_discovery.cpp b/tests/slic3rutils/test_moonraker_discovery.cpp
deleted file mode 100644
index f4df147225..0000000000
--- a/tests/slic3rutils/test_moonraker_discovery.cpp
+++ /dev/null
@@ -1,51 +0,0 @@
-#include
-
-#include
-#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
-
-using namespace Slic3r;
-
-namespace {
-
-class DiscoveryAgent : public MoonrakerPrinterAgent
-{
-public:
- using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
- using MoonrakerPrinterAgent::device_info;
- mutable unsigned fetch_count = 0;
-
-protected:
- bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
- {
- ++fetch_count;
- info.dev_name = "Test printer";
- return true;
- }
-};
-
-} // namespace
-
-TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerDiscovery]")
-{
- DiscoveryAgent agent("");
- unsigned announcements = 0;
- nlohmann::json announcement;
- agent.set_on_ssdp_msg_fn([&](std::string payload) {
- ++announcements;
- announcement = nlohmann::json::parse(payload);
- });
-
- agent.start_discovery(true, false);
- REQUIRE(announcements == 0);
- REQUIRE(agent.fetch_count == 0);
-
- agent.device_info.dev_id = "127.0.0.1:7125";
- agent.device_info.dev_ip = agent.device_info.dev_id;
- agent.device_info.base_url = "http://127.0.0.1:7125";
- agent.start_discovery(true, false);
- REQUIRE(announcements == 1);
- REQUIRE(agent.fetch_count == 1);
- REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
- REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
- REQUIRE(announcement.at("dev_name") == "Test printer");
-}
diff --git a/tests/slic3rutils/test_moonraker_printer_agent.cpp b/tests/slic3rutils/test_moonraker_printer_agent.cpp
new file mode 100644
index 0000000000..32dca2ffd3
--- /dev/null
+++ b/tests/slic3rutils/test_moonraker_printer_agent.cpp
@@ -0,0 +1,580 @@
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "slic3r/Utils/IPrinterAgent.hpp"
+#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
+
+using namespace Slic3r;
+
+namespace {
+
+namespace beast = boost::beast;
+namespace http = beast::http;
+namespace websocket = beast::websocket;
+namespace net = boost::asio;
+using tcp = net::ip::tcp;
+using namespace std::chrono_literals;
+
+class DiscoveryAgent : public MoonrakerPrinterAgent
+{
+public:
+ using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
+ using MoonrakerPrinterAgent::device_info;
+ mutable unsigned fetch_count = 0;
+
+protected:
+ bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
+ {
+ ++fetch_count;
+ info.dev_name = "Test printer";
+ return true;
+ }
+};
+
+class StreamAgent : public MoonrakerPrinterAgent
+{
+public:
+ using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
+ using MoonrakerPrinterAgent::start_status_stream;
+ using MoonrakerPrinterAgent::stop_status_stream;
+ using MoonrakerPrinterAgent::ws_timings;
+
+protected:
+ bool init_device_info(
+ const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
+ {
+ device_info = MoonrakerDeviceInfo{};
+ device_info.dev_id = dev_id;
+ device_info.base_url = "http://" + dev_ip + ":" + port;
+ return true;
+ }
+
+ bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
+ {
+ info.dev_name = "Test printer";
+ return true;
+ }
+};
+
+enum class Behavior {
+ SilentBeforeHandshake, // Accepts the connection and never answers
+ Silent, // Completes the upgrade, then never reads or sends
+ AnswersPings, // Reports a status, then answers pings and sends nothing else
+ AnswersPingsWithoutStatus, // Answers pings but never reports a status
+ Chatters, // Reports a status, then answers pings and sends notifications that carry no status
+ HangsUp, // Reports a status, then closes the websocket
+ HangsUpWithoutAnswer, // Closes the websocket without sending anything
+ IgnoresClose, // Reports Klippy disconnected, then never answers the close
+ StallsObjectList, // Never answers the stream's object list request
+ StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
+};
+
+class FakePrinter
+{
+public:
+ explicit FakePrinter(Behavior behavior) : m_behavior(behavior)
+ {
+ m_accept_thread = std::thread([this] { accept_loop(); });
+ }
+
+ ~FakePrinter()
+ {
+ release();
+ m_stopping = true;
+ // Wakes the blocking accept.
+ tcp::socket wake{m_accept_ioc};
+ beast::error_code ec;
+ wake.connect(m_acceptor.local_endpoint(), ec);
+ m_accept_thread.join();
+ for (std::thread& connection : m_connections)
+ connection.join();
+ }
+
+ std::string host() const { return "127.0.0.1"; }
+ std::string port() const { return std::to_string(m_acceptor.local_endpoint().port()); }
+ std::string base_url() const { return "http://" + host() + ":" + port(); }
+
+ // A connection is ready once it reaches the state its behavior waits in.
+ bool wait_for_ready_connections(int count, std::chrono::milliseconds timeout = 10s)
+ {
+ std::unique_lock lock(m_mutex);
+ return m_changed.wait_for(lock, timeout, [&] { return m_ready >= count; });
+ }
+
+ bool wait_for_http_requests(int count)
+ {
+ std::unique_lock lock(m_mutex);
+ return m_changed.wait_for(lock, 10s, [&] { return m_http_requests >= count; });
+ }
+
+ // Waits until the printer, once released, has hung up on `count` stalled HTTP requests.
+ bool wait_for_http_hang_ups(int count)
+ {
+ std::unique_lock lock(m_mutex);
+ return m_changed.wait_for(lock, 10s, [&] { return m_http_hung_up >= count; });
+ }
+
+ int close_frames()
+ {
+ std::lock_guard lock(m_mutex);
+ return m_close_frames;
+ }
+
+ // Hangs up every connection that is waiting.
+ void release()
+ {
+ std::lock_guard lock(m_mutex);
+ m_released = true;
+ m_changed.notify_all();
+ }
+
+private:
+ void accept_loop()
+ {
+ for (;;) {
+ // Each connection gets its own io_context, so a connection that runs one never runs another's handlers.
+ auto ioc = std::make_unique();
+ tcp::socket socket{*ioc};
+ beast::error_code ec;
+ m_acceptor.accept(socket, ec);
+ if (ec || m_stopping)
+ return;
+ m_connections.emplace_back(
+ [this, ioc = std::move(ioc), socket = std::move(socket)]() mutable { serve(*ioc, std::move(socket)); });
+ }
+ }
+
+ void serve(net::io_context& ioc, tcp::socket socket)
+ {
+ if (m_behavior == Behavior::SilentBeforeHandshake) {
+ mark_ready();
+ wait_for_release();
+ return;
+ }
+
+ beast::flat_buffer buffer;
+ http::request request;
+ beast::error_code ec;
+ http::read(socket, buffer, request, ec);
+ if (ec)
+ return;
+ // The agent also queries the printer's status and object list over plain HTTP.
+ if (!websocket::is_upgrade(request)) {
+ serve_http(socket, request);
+ return;
+ }
+
+ websocket::stream ws{std::move(socket)};
+ ws.accept(request, ec);
+ // Without its object list the stream never sends the subscribe request.
+ const int requests = m_behavior == Behavior::StallsObjectList ? 1 : 2;
+ for (int i = 0; i < requests && !ec; ++i) {
+ beast::flat_buffer message;
+ ws.read(message, ec);
+ }
+ if (!ec && (m_behavior == Behavior::AnswersPings || m_behavior == Behavior::Chatters || m_behavior == Behavior::HangsUp)) {
+ const std::string status =
+ nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"result", {{"status", {{"print_stats", {{"state", "standby"}}}}}}}}.dump();
+ ws.write(net::buffer(status), ec);
+ }
+ if (ec)
+ return;
+
+ switch (m_behavior) {
+ case Behavior::AnswersPings:
+ case Behavior::AnswersPingsWithoutStatus:
+ case Behavior::Chatters:
+ mark_ready();
+ answer_pings(ioc, ws, m_behavior == Behavior::Chatters);
+ break;
+ case Behavior::HangsUp:
+ case Behavior::HangsUpWithoutAnswer:
+ ws.close(websocket::close_code::normal, ec);
+ mark_ready();
+ break;
+ case Behavior::IgnoresClose: {
+ const std::string notification = nlohmann::json{{"jsonrpc", "2.0"}, {"method", "notify_klippy_disconnected"}}.dump();
+ ws.write(net::buffer(notification), ec);
+ mark_ready();
+ wait_for_release();
+ break;
+ }
+ case Behavior::StallsObjectList: answer_pings(ioc, ws, false); break;
+ default: mark_ready(); wait_for_release();
+ }
+ }
+
+ void serve_http(tcp::socket& socket, const http::request& request)
+ {
+ {
+ std::lock_guard lock(m_mutex);
+ ++m_http_requests;
+ m_changed.notify_all();
+ }
+ if (m_behavior == Behavior::StallsObjectList) {
+ mark_ready();
+ wait_for_release();
+ return;
+ }
+ if (m_behavior == Behavior::StallsStatusQuery) {
+ wait_for_release();
+ beast::error_code ec;
+ socket.close(ec);
+ std::lock_guard lock(m_mutex);
+ ++m_http_hung_up;
+ m_changed.notify_all();
+ return;
+ }
+ http::response response{http::status::not_found, request.version()};
+ response.prepare_payload();
+ beast::error_code ec;
+ http::write(socket, response, ec);
+ }
+
+ // Keeps a read pending, which answers pings, until released or closed, and with `chatter` also sends a notification every 50 ms.
+ void answer_pings(net::io_context& ioc, websocket::stream& ws, bool chatter)
+ {
+ const std::string notification = nlohmann::json{{"jsonrpc", "2.0"},
+ {"method", "notify_proc_stat_update"},
+ {"params", nlohmann::json::array({nlohmann::json::object()})}}
+ .dump();
+ beast::flat_buffer message;
+ bool reading = true;
+ bool writing = false;
+ std::function read_next = [&] {
+ ws.async_read(message, [&](beast::error_code ec, std::size_t) {
+ message.clear();
+ if (ec == websocket::error::closed) {
+ std::lock_guard lock(m_mutex);
+ ++m_close_frames;
+ }
+ if (ec)
+ reading = false;
+ else
+ read_next();
+ });
+ };
+ read_next();
+
+ auto next_notification = std::chrono::steady_clock::now();
+ while (reading && !wait_for_release(10ms)) {
+ if (chatter && !writing && std::chrono::steady_clock::now() >= next_notification) {
+ writing = true;
+ next_notification += 50ms;
+ ws.async_write(net::buffer(notification), [&](beast::error_code, std::size_t) { writing = false; });
+ }
+ ioc.restart();
+ ioc.run_for(20ms);
+ }
+ beast::error_code ignored;
+ ws.next_layer().close(ignored);
+ while (reading || writing) {
+ ioc.restart();
+ ioc.run_for(20ms);
+ }
+ }
+
+ void mark_ready()
+ {
+ std::lock_guard lock(m_mutex);
+ ++m_ready;
+ m_changed.notify_all();
+ }
+
+ void wait_for_release()
+ {
+ std::unique_lock lock(m_mutex);
+ m_changed.wait(lock, [this] { return m_released; });
+ }
+
+ bool wait_for_release(std::chrono::milliseconds timeout)
+ {
+ std::unique_lock lock(m_mutex);
+ return m_changed.wait_for(lock, timeout, [this] { return m_released; });
+ }
+
+ const Behavior m_behavior;
+ net::io_context m_accept_ioc;
+ tcp::acceptor m_acceptor{m_accept_ioc, {net::ip::make_address("127.0.0.1"), 0}};
+ std::atomic m_stopping{false};
+ std::thread m_accept_thread;
+ std::vector m_connections;
+ std::mutex m_mutex;
+ std::condition_variable m_changed;
+ int m_ready = 0;
+ int m_http_requests = 0;
+ int m_http_hung_up = 0;
+ int m_close_frames = 0;
+ bool m_released = false;
+};
+
+// Records when the agent dispatches a status report.
+class Reports
+{
+public:
+ void attach(StreamAgent& agent)
+ {
+ agent.set_on_local_message_fn([this](std::string, std::string) {
+ std::lock_guard lock(m_mutex);
+ m_times.push_back(std::chrono::steady_clock::now());
+ m_changed.notify_all();
+ });
+ }
+
+ bool wait_for(std::size_t count)
+ {
+ std::unique_lock lock(m_mutex);
+ return m_changed.wait_for(lock, 10s, [&] { return m_times.size() >= count; });
+ }
+
+ std::vector times()
+ {
+ std::lock_guard lock(m_mutex);
+ return m_times;
+ }
+
+private:
+ std::mutex m_mutex;
+ std::condition_variable m_changed;
+ std::vector m_times;
+};
+
+// A printer and a stream agent connected to it.
+struct StreamFixture
+{
+ explicit StreamFixture(Behavior behavior) : printer(behavior) {}
+
+ // Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
+ ~StreamFixture() { printer.release(); }
+
+ void start() { agent.start_status_stream("printer", printer.base_url(), ""); }
+
+ // Stops the stream, giving it `limit` to return before the printer hangs up on it.
+ bool stops_within(std::chrono::milliseconds limit)
+ {
+ auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
+ const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
+ printer.release();
+ stopped.wait();
+ return in_time;
+ }
+
+ FakePrinter printer;
+ StreamAgent agent{""};
+};
+
+} // namespace
+
+TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerPrinterAgent][Discovery]")
+{
+ DiscoveryAgent agent("");
+ unsigned announcements = 0;
+ nlohmann::json announcement;
+ agent.set_on_ssdp_msg_fn([&](std::string payload) {
+ ++announcements;
+ announcement = nlohmann::json::parse(payload);
+ });
+
+ agent.start_discovery(true, false);
+ REQUIRE(announcements == 0);
+ REQUIRE(agent.fetch_count == 0);
+
+ agent.device_info.dev_id = "127.0.0.1:7125";
+ agent.device_info.dev_ip = agent.device_info.dev_id;
+ agent.device_info.base_url = "http://127.0.0.1:7125";
+ agent.start_discovery(true, false);
+ REQUIRE(announcements == 1);
+ REQUIRE(agent.fetch_count == 1);
+ REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
+ REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
+ REQUIRE(announcement.at("dev_name") == "Test printer");
+}
+
+TEST_CASE("Stopping the status stream gives a silent printer no more than its timeouts", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ // An open websocket gets the 200 ms close timeout, and a stalled object list request up to 1 s of curl progress to see the stop.
+ const auto [behavior, limit] = GENERATE(table({
+ {Behavior::SilentBeforeHandshake, 500ms},
+ {Behavior::Silent, 1000ms},
+ {Behavior::StallsObjectList, 2000ms},
+ }));
+ CAPTURE(static_cast(behavior));
+
+ StreamFixture f(behavior);
+ f.agent.ws_timings.close = 200ms;
+ f.start();
+ REQUIRE(f.printer.wait_for_ready_connections(1));
+
+ CHECK(f.stops_within(limit));
+}
+
+TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ // A stop while the stream reads, and one while it waits on the object list request.
+ const Behavior behavior = GENERATE(Behavior::AnswersPings, Behavior::StallsObjectList);
+ CAPTURE(static_cast(behavior));
+
+ StreamFixture f(behavior);
+ f.start();
+ REQUIRE(f.printer.wait_for_ready_connections(1));
+
+ CHECK(f.stops_within(3s));
+ CHECK(f.printer.close_frames() == 1);
+}
+
+TEST_CASE("Stopping the status stream does not wait out the reconnect delay", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ StreamFixture f(Behavior::HangsUp);
+ f.agent.ws_timings.reconnect_delay = 10s;
+ f.start();
+ REQUIRE(f.printer.wait_for_ready_connections(1));
+ // Lets the stream reach its reconnect wait.
+ std::this_thread::sleep_for(200ms);
+
+ CHECK(f.stops_within(3s));
+}
+
+TEST_CASE("The status stream reconnects when the printer stops answering", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ // An unanswered close ends at whichever of the handshake and close timeouts is shorter.
+ const auto [behavior, handshake, close] = GENERATE(table({
+ {Behavior::Silent, 300ms, 1000ms},
+ {Behavior::IgnoresClose, 300ms, 1000ms},
+ {Behavior::IgnoresClose, 1000ms, 200ms},
+ }));
+ CAPTURE(static_cast(behavior), handshake.count(), close.count());
+
+ StreamFixture f(behavior);
+ f.agent.ws_timings.handshake = handshake;
+ f.agent.ws_timings.close = close;
+ f.agent.ws_timings.idle = 400ms;
+ f.agent.ws_timings.reconnect_delay = 20ms;
+ f.start();
+
+ CHECK(f.printer.wait_for_ready_connections(2));
+ CHECK(f.stops_within(3s));
+}
+
+TEST_CASE("The status stream reconnects at the shortest delay while the printer answers", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ StreamFixture f(Behavior::HangsUp);
+ f.agent.ws_timings.reconnect_delay = 50ms;
+ f.start();
+
+ // Twelve 50 ms waits; a backoff that kept doubling would need over 10 s.
+ CHECK(f.printer.wait_for_ready_connections(12, 3s));
+ CHECK(f.stops_within(3s));
+}
+
+TEST_CASE("The status stream keeps retrying a printer that never answers", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ StreamFixture f(Behavior::HangsUpWithoutAnswer);
+ f.agent.ws_timings.reconnect_delay = 1ms;
+ f.start();
+
+ // The backoff stops doubling at 32 ms, so 20 attempts take well under the wait.
+ CHECK(f.printer.wait_for_ready_connections(20));
+ CHECK(f.stops_within(3s));
+}
+
+TEST_CASE("A callback on the stream thread can stop the status stream", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ std::promise stopped;
+ std::once_flag stopped_once;
+ StreamFixture f(Behavior::AnswersPings);
+ f.agent.set_on_local_message_fn([&](std::string, std::string) {
+ std::call_once(stopped_once, [&] {
+ f.agent.stop_status_stream();
+ stopped.set_value();
+ });
+ });
+ f.start();
+
+ REQUIRE(stopped.get_future().wait_for(5s) == std::future_status::ready);
+ CHECK(f.stops_within(3s));
+}
+
+TEST_CASE("A quiet printer that is still up keeps reporting its status", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ Reports reports;
+ StreamFixture f(Behavior::AnswersPings);
+ f.agent.ws_timings.idle = 400ms;
+ f.agent.ws_timings.heartbeat = 300ms;
+ reports.attach(f.agent);
+ f.start();
+
+ // The status the printer sent, then two heartbeats.
+ REQUIRE(reports.wait_for(3));
+ const auto times = reports.times();
+ // The steady clock is compared in whole milliseconds, so allow 1 ms of rounding.
+ CHECK(times[1] - times[0] >= 299ms);
+ CHECK(times[2] - times[1] >= 299ms);
+ CHECK(f.stops_within(3s));
+}
+
+TEST_CASE("The status stream sends no heartbeat while the printer has nothing new to report", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ const Behavior behavior = GENERATE(Behavior::Chatters, Behavior::AnswersPingsWithoutStatus);
+ CAPTURE(static_cast(behavior));
+
+ Reports reports;
+ StreamFixture f(behavior);
+ f.agent.ws_timings.idle = 400ms;
+ f.agent.ws_timings.heartbeat = 800ms;
+ reports.attach(f.agent);
+ f.start();
+ REQUIRE(f.printer.wait_for_ready_connections(1));
+
+ // The chattering printer's status, and nothing from the one that never sent a status.
+ const std::size_t expected = behavior == Behavior::Chatters ? 1 : 0;
+ std::this_thread::sleep_for(1500ms);
+ CHECK(reports.times().size() == expected);
+ CHECK(f.stops_within(3s));
+}
+
+TEST_CASE("A disconnect during the connection queries keeps the status stream from starting", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ StreamFixture f(Behavior::StallsStatusQuery);
+ PrinterConnectionParams params;
+ params.dev_id = "printer";
+ params.host = f.printer.host();
+ params.port = f.printer.port();
+ f.agent.connect_printer(params);
+ REQUIRE(f.printer.wait_for_http_requests(1));
+
+ f.agent.disconnect_printer();
+ f.printer.release();
+ // The query has returned once the printer hangs up on it, and a stream would connect right after.
+ REQUIRE(f.printer.wait_for_http_hang_ups(1));
+ CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
+}
From d16ca973802cc884f6bc463b140403a30bb9bc6d Mon Sep 17 00:00:00 2001
From: Kris Austin
Date: Sun, 18 Oct 2026 21:35:32 -0500
Subject: [PATCH 8/8] Snapmaker U1: set the top cover purifier mode and match
Snapmaker's start G-code (#16590)
Port the U1 top cover purifier mode and the related start G-code
changes from Snapmaker's Orca (Snapmaker/OrcaSlicer#504 and
Snapmaker/OrcaSlicer#1005, as of 8931178229).
The start G-code sends SET_PURIFIER_MODE for strong cooling, weak
cooling or keep warm, picked from the lowest softening temperature of
the filaments used through min_vitrification_temperature, with
Snapmaker's thresholds and parameters. It also pre-extrudes the first
tool, and turns the part fan off for probing and back on for nozzle
cleaning.
U1 filament presets use Snapmaker's softening temperatures. U1 presets
that have no U1 version in Snapmaker's Orca use the value Snapmaker
gives that material on the U1.
---
resources/profiles/Snapmaker.json | 2 +-
.../profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json | 3 +++
.../profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json | 3 +++
.../profiles/Snapmaker/filament/Snapmaker PETG @U1 base.json | 2 +-
.../filament/Snapmaker PETG Translucent @U1 base.json | 5 ++++-
.../Snapmaker/filament/Snapmaker PETG-CF @U1 base.json | 2 +-
.../profiles/Snapmaker/filament/Snapmaker PLA @U1 base.json | 3 +++
.../Snapmaker/filament/Snapmaker PLA Eco @U1 base.json | 3 +++
.../Snapmaker/filament/Snapmaker PLA Glow @U1 base.json | 3 +++
.../Snapmaker/filament/Snapmaker PLA Metal @U1 base.json | 3 +++
.../profiles/Snapmaker/filament/Snapmaker PVA @U1 base.json | 3 +++
resources/profiles/Snapmaker/filament/Snapmaker TPE @U1.json | 3 +++
.../Snapmaker/filament/Snapmaker TPU 95A @U1 base.json | 2 +-
.../profiles/Snapmaker/filament/Snapmaker TPU 95A @U1.json | 2 +-
resources/profiles/Snapmaker/filament/Snapmaker TPU @U1.json | 3 +++
.../Snapmaker/filament/Snapmaker TPU High-Flow @U1.json | 3 +++
.../Snapmaker/machine/Snapmaker U1 (0.2 nozzle).json | 2 +-
.../Snapmaker/machine/Snapmaker U1 (0.4 nozzle).json | 2 +-
.../Snapmaker/machine/Snapmaker U1 (0.6 nozzle).json | 2 +-
.../Snapmaker/machine/Snapmaker U1 (0.8 nozzle).json | 2 +-
20 files changed, 43 insertions(+), 10 deletions(-)
diff --git a/resources/profiles/Snapmaker.json b/resources/profiles/Snapmaker.json
index 4978095364..4abf624a41 100644
--- a/resources/profiles/Snapmaker.json
+++ b/resources/profiles/Snapmaker.json
@@ -1,6 +1,6 @@
{
"name": "Snapmaker",
- "version": "02.04.00.17",
+ "version": "02.04.00.18",
"force_update": "0",
"description": "Snapmaker configurations",
"machine_model_list": [
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json
index af8c929c82..df243f2bbb 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json
@@ -49,5 +49,8 @@
],
"pressure_advance": [
"0.02"
+ ],
+ "temperature_vitrification": [
+ "100"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json
index 409bbdd793..e566ab4682 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json
@@ -46,5 +46,8 @@
],
"fan_min_speed": [
"0"
+ ],
+ "temperature_vitrification": [
+ "100"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker PETG @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker PETG @U1 base.json
index d081c6f9d8..e9164a7c2f 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker PETG @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker PETG @U1 base.json
@@ -45,7 +45,7 @@
"250"
],
"temperature_vitrification": [
- "178"
+ "70"
],
"fan_max_speed": [
"20"
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker PETG Translucent @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker PETG Translucent @U1 base.json
index 6b51cd2535..5a712d7483 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker PETG Translucent @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker PETG Translucent @U1 base.json
@@ -4,5 +4,8 @@
"inherits": "fdm_filament_petg",
"from": "system",
"filament_id": "OFW8lqnb",
- "instantiation": "false"
+ "instantiation": "false",
+ "temperature_vitrification": [
+ "70"
+ ]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker PETG-CF @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker PETG-CF @U1 base.json
index 076dc7e2b9..562c7ce6e3 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker PETG-CF @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker PETG-CF @U1 base.json
@@ -60,7 +60,7 @@
"250"
],
"temperature_vitrification": [
- "178"
+ "70"
],
"fan_max_speed": [
"20"
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker PLA @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker PLA @U1 base.json
index 3f41d5579e..f0a41e4cc1 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker PLA @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker PLA @U1 base.json
@@ -34,5 +34,8 @@
],
"nozzle_temperature": [
"220"
+ ],
+ "temperature_vitrification": [
+ "45"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker PLA Eco @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker PLA Eco @U1 base.json
index 67e0d74b2e..23586149c3 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker PLA Eco @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker PLA Eco @U1 base.json
@@ -34,5 +34,8 @@
],
"filament_cooling_final_speed": [
"60"
+ ],
+ "temperature_vitrification": [
+ "45"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker PLA Glow @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker PLA Glow @U1 base.json
index 97d2a7ea61..15e1d514ad 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker PLA Glow @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker PLA Glow @U1 base.json
@@ -34,5 +34,8 @@
],
"nozzle_temperature": [
"220"
+ ],
+ "temperature_vitrification": [
+ "45"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker PLA Metal @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker PLA Metal @U1 base.json
index 87c26fdb09..63648c9206 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker PLA Metal @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker PLA Metal @U1 base.json
@@ -40,5 +40,8 @@
],
"nozzle_temperature": [
"220"
+ ],
+ "temperature_vitrification": [
+ "45"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker PVA @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker PVA @U1 base.json
index f2b2033dd9..4444e1f7ab 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker PVA @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker PVA @U1 base.json
@@ -31,5 +31,8 @@
],
"slow_down_layer_time": [
"8"
+ ],
+ "temperature_vitrification": [
+ "45"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker TPE @U1.json b/resources/profiles/Snapmaker/filament/Snapmaker TPE @U1.json
index 5f6820fa39..cedc844340 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker TPE @U1.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker TPE @U1.json
@@ -50,5 +50,8 @@
"additional_cooling_fan_speed": [
"70",
"70"
+ ],
+ "temperature_vitrification": [
+ "30"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker TPU 95A @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker TPU 95A @U1 base.json
index d61e2adb1b..aa0899f247 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker TPU 95A @U1 base.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker TPU 95A @U1 base.json
@@ -137,7 +137,7 @@
"240"
],
"temperature_vitrification": [
- "60"
+ "30"
],
"nozzle_temperature_range_low": [
"200"
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker TPU 95A @U1.json b/resources/profiles/Snapmaker/filament/Snapmaker TPU 95A @U1.json
index 6d5c2c4c73..474b9a0620 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker TPU 95A @U1.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker TPU 95A @U1.json
@@ -302,7 +302,7 @@
"-1"
],
"temperature_vitrification": [
- "60"
+ "30"
],
"textured_cool_plate_temp": [
"40"
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker TPU @U1.json b/resources/profiles/Snapmaker/filament/Snapmaker TPU @U1.json
index 7de9731406..c4f9ee7985 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker TPU @U1.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker TPU @U1.json
@@ -29,5 +29,8 @@
"additional_cooling_fan_speed": [
"70",
"70"
+ ],
+ "temperature_vitrification": [
+ "30"
]
}
diff --git a/resources/profiles/Snapmaker/filament/Snapmaker TPU High-Flow @U1.json b/resources/profiles/Snapmaker/filament/Snapmaker TPU High-Flow @U1.json
index a3afba536b..dfa2fe0526 100644
--- a/resources/profiles/Snapmaker/filament/Snapmaker TPU High-Flow @U1.json
+++ b/resources/profiles/Snapmaker/filament/Snapmaker TPU High-Flow @U1.json
@@ -49,5 +49,8 @@
"additional_cooling_fan_speed": [
"70",
"70"
+ ],
+ "temperature_vitrification": [
+ "30"
]
}
diff --git a/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.2 nozzle).json b/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.2 nozzle).json
index 6971ed458f..99dda99752 100644
--- a/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.2 nozzle).json
+++ b/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.2 nozzle).json
@@ -97,7 +97,7 @@
"20",
"12"
],
- "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
+ "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
"machine_tool_change_time": "5",
"max_layer_height": [
"0.14",
diff --git a/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.4 nozzle).json b/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.4 nozzle).json
index 8438b121cb..be5af23449 100644
--- a/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.4 nozzle).json
+++ b/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.4 nozzle).json
@@ -97,7 +97,7 @@
"20",
"12"
],
- "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
+ "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
"machine_tool_change_time": "5",
"max_layer_height": [
"0.32",
diff --git a/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.6 nozzle).json b/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.6 nozzle).json
index ec01b97698..41fb2bf76e 100644
--- a/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.6 nozzle).json
+++ b/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.6 nozzle).json
@@ -97,7 +97,7 @@
"20",
"12"
],
- "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
+ "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
"machine_tool_change_time": "5",
"max_layer_height": [
"0.42",
diff --git a/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.8 nozzle).json b/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.8 nozzle).json
index 5bc11525e9..6c31cf7633 100644
--- a/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.8 nozzle).json
+++ b/resources/profiles/Snapmaker/machine/Snapmaker U1 (0.8 nozzle).json
@@ -97,7 +97,7 @@
"20",
"12"
],
- "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
+ "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
"machine_tool_change_time": "5",
"max_layer_height": [
"0.56",