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/.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/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/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",
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
# ---------------------------------------------------------------------------
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.";
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);
}
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/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();
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();
diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
index 7ce8ec14cd..5f37c3d40d 100644
--- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
+++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
@@ -21,13 +21,16 @@
#include
#include
#include
+#include
#include
#include
+#include
#include
#include
#include
#include
#include
+#include
#include
#include
#include
@@ -35,6 +38,8 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -161,6 +166,168 @@ 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());
+}
+
+// Verification context shared by the synchronous and the asynchronous websocket: the custom CA
+// when one is configured, the platform trust store otherwise, and always a verified peer.
+std::unique_ptr make_tls_context(const std::string& ca_file)
+{
+ auto context = std::make_unique(net::ssl::context::tls_client);
+ if (!ca_file.empty()) {
+ context->load_verify_file(ca_file);
+ } else {
+ context->set_default_verify_paths();
+ }
+ context->set_verify_mode(net::ssl::verify_peer);
+ return context;
+}
+
+// Commits the SNI name and requires the leaf certificate to match the requested host, not just
+// to chain to a trusted CA.
+void require_matching_certificate(beast::ssl_stream& stream, const std::string& host)
+{
+ if (!SSL_set_tlsext_host_name(stream.native_handle(), host.c_str())) {
+ throw std::runtime_error("Moonraker WSS: failed to set TLS server name");
+ }
+ stream.set_verify_callback(net::ssl::host_name_verification(host));
+}
+
+// Asynchronous websocket over either plain TCP or TLS, decided once from the endpoint. Every
+// operation is asynchronous so run_until_done() can give it a deadline and abort it on stop.
+class AsyncWebsocket
+{
+public:
+ using PlainWebsocket = websocket::stream;
+ using SecureWebsocket = websocket::stream>;
+ using PlainPtr = std::unique_ptr;
+ using SecurePtr = std::unique_ptr;
+
+ AsyncWebsocket(net::io_context& ioc, bool secure, std::string api_key, std::string ca_file)
+ : m_secure(secure), m_api_key(std::move(api_key))
+ {
+ if (m_secure) {
+ // Build the TLS context lazily so plaintext connections do not initialize OpenSSL.
+ m_ssl_context = make_tls_context(ca_file);
+ m_websocket = std::make_unique(ioc, *m_ssl_context);
+ } else {
+ m_websocket = std::make_unique(beast::tcp_stream{ioc});
+ }
+ }
+
+ bool is_open() const { return std::visit([](const auto& ws) { return ws->is_open(); }, m_websocket); }
+
+ beast::tcp_stream& lowest_layer()
+ {
+ return std::visit([](auto& ws) -> beast::tcp_stream& { return beast::get_lowest_layer(*ws); }, m_websocket);
+ }
+
+ void expires_after(std::chrono::milliseconds timeout) { lowest_layer().expires_after(timeout); }
+ void expires_never() { lowest_layer().expires_never(); }
+
+ void close_socket()
+ {
+ beast::error_code ignored;
+ lowest_layer().socket().close(ignored);
+ }
+
+ template
+ void async_connect(const tcp::endpoint& endpoint, Handler&& handler)
+ {
+ std::visit([&](auto& ws) { beast::get_lowest_layer(*ws).async_connect(endpoint, std::forward(handler)); }, m_websocket);
+ }
+
+ template
+ void async_tls_handshake(const std::string& host, Handler&& handler)
+ {
+ if (!m_secure) {
+ handler(beast::error_code{});
+ return;
+ }
+ auto& stream = std::get(m_websocket)->next_layer();
+ require_matching_certificate(stream, host);
+ stream.async_handshake(net::ssl::stream_base::client, std::forward(handler));
+ }
+
+ void set_timeout(const websocket::stream_base::timeout& timeouts)
+ {
+ std::visit([&](auto& ws) { ws->set_option(timeouts); }, m_websocket);
+ }
+
+ template
+ void control_callback(Fn&& fn)
+ {
+ std::visit([&](auto& ws) { ws->control_callback(std::forward(fn)); }, m_websocket);
+ }
+
+ template
+ void async_handshake(const std::string& host, const std::string& target, Handler&& handler)
+ {
+ std::visit(
+ [&](auto& ws) {
+ ws->set_option(websocket::stream_base::decorator([api_key = m_api_key](websocket::request_type& req) {
+ req.set(http::field::user_agent, "OrcaSlicer");
+ if (!api_key.empty()) {
+ req.set("X-Api-Key", api_key);
+ }
+ }));
+ ws->async_handshake(host, target, std::forward(handler));
+ },
+ m_websocket);
+ }
+
+ void text(bool enabled)
+ {
+ std::visit([&](auto& ws) { ws->text(enabled); }, m_websocket);
+ }
+
+ template
+ void async_write(const std::string& body, Handler&& handler)
+ {
+ std::visit([&](auto& ws) { ws->async_write(net::buffer(body), std::forward(handler)); }, m_websocket);
+ }
+
+ template
+ void async_read(beast::flat_buffer& buffer, Handler&& handler)
+ {
+ std::visit([&](auto& ws) { ws->async_read(buffer, std::forward(handler)); }, m_websocket);
+ }
+
+ template
+ void async_close(websocket::close_code code, Handler&& handler)
+ {
+ std::visit([&](auto& ws) { ws->async_close(code, std::forward(handler)); }, m_websocket);
+ }
+
+private:
+ bool m_secure;
+ std::string m_api_key;
+ std::unique_ptr m_ssl_context;
+ std::variant m_websocket;
+};
+
} // namespace
namespace Slic3r {
@@ -176,15 +343,8 @@ struct MoonrakerWebsocket::Impl
: secure(secure), api_key(std::move(api_key)), ca_file(std::move(ca_file))
{
if (this->secure) {
- // Build the TLS context lazily so plaintext connections do not initialize OpenSSL.
- ssl_context = std::make_unique(net::ssl::context::tls_client);
- if (!this->ca_file.empty()) {
- ssl_context->load_verify_file(this->ca_file);
- } else {
- ssl_context->set_default_verify_paths();
- }
- ssl_context->set_verify_mode(net::ssl::verify_peer);
- websocket = std::make_unique(ioc, *ssl_context);
+ ssl_context = make_tls_context(this->ca_file);
+ websocket = std::make_unique(ioc, *ssl_context);
} else {
websocket = std::make_unique(beast::tcp_stream{ioc});
}
@@ -224,13 +384,7 @@ void MoonrakerWebsocket::tls_handshake(const std::string& host)
auto& websocket = *std::get(m_impl->websocket);
auto& tls_stream = websocket.next_layer();
- if (!SSL_set_tlsext_host_name(tls_stream.native_handle(), host.c_str())) {
- throw std::runtime_error("Moonraker WSS: failed to set TLS server name");
- }
-
- // verify_peer only validates the chain; also require the leaf certificate to match the
- // host we asked for, otherwise any cert chaining to a trusted CA is accepted.
- tls_stream.set_verify_callback(net::ssl::host_name_verification(host));
+ require_matching_certificate(tls_stream, host);
tls_stream.handshake(net::ssl::stream_base::client);
}
@@ -2419,76 +2573,171 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, Conne
return;
}
+ std::lock_guard lock(ws_abort_mutex);
ws_stop.store(false);
webcam_info_next_attempt_ms.store(0); // new connection: refresh the webcam promptly
- ws_thread = std::thread([this, dev_id, connection = std::move(connection)]() mutable {
- run_status_stream(dev_id, std::move(connection));
+ ws_thread = std::thread([this, dev_id, connection = std::move(connection), timings = ws_timings]() mutable {
+ run_status_stream(dev_id, std::move(connection), timings);
});
}
void MoonrakerPrinterAgent::stop_status_stream()
{
- ws_stop.store(true);
+ std::thread stopped;
{
- // Wake a blocked synchronous ws.connect()/tls_handshake()/read()/write() in
- // run_status_stream(); ws_stop by itself is only observed between reads.
std::lock_guard lock(ws_abort_mutex);
- if (ws_abort_io) {
- ws_abort_io();
+ 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);
}
}
- ws_wait_cv.notify_all();
- if (ws_thread.joinable()) {
- ws_thread.join();
+ if (stopped.joinable()) {
+ stopped.join();
}
}
-void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, ConnectionSettings connection)
+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, ConnectionSettings connection, StatusStreamTimings timings)
{
WsEndpoint endpoint;
if (!parse_ws_endpoint(connection.base_url, endpoint)) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket endpoint invalid for base_url=" << connection.base_url;
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
- try {
- MoonrakerWebsocket ws{endpoint.secure, connection.api_key, connection.ca_file};
-
- // Allow stop_status_stream() to force this socket shut so a blocked synchronous
- // ws.connect()/tls_handshake()/read()/write() returns with an error (Beast's
- // expires_after() does not bound synchronous operations). Registered before
- // connect() so a stalled handshake is interruptible too; declared after `ws`
- // so the hook is cleared before `ws` is destroyed on every exit path
- // (fallthrough, break, exception); ws_abort_mutex keeps the hook from running
- // against a half-destroyed `ws`.
- ScopeGuard ws_abort_guard([this] {
- std::lock_guard lock(ws_abort_mutex);
- ws_abort_io = nullptr;
- });
- {
- std::lock_guard lock(ws_abort_mutex);
- ws_abort_io = [&ws] {
- ws.abort();
- };
+ tcp::resolver resolver{ioc};
+ std::unique_ptr ws; // Created inside the try so a TLS context failure is caught and retried
+ 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 || !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) {
+ ws->close_socket();
+ }
+ });
+ 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 (!ws || closing) {
+ return;
+ }
+ if (ws->is_open()) {
+ close_politely(websocket::close_code::going_away);
+ } else {
+ ws->close_socket();
+ }
+ };
+ const ScopeGuard clear_connection_abort([&] { abort_connection = nullptr; });
- ws.connect(endpoint.host, endpoint.port, std::chrono::seconds(10));
- ws.tls_handshake(endpoint.host);
+ try {
+ ws = std::make_unique(ioc, endpoint.secure, connection.api_key, connection.ca_file);
+
+ beast::error_code connect_ec = net::error::host_not_found;
+ for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
+ ws->close_socket(); // A failed attempt leaves the socket open
+ ws->expires_after(std::chrono::seconds(5));
+ connect_ec = run([&](auto&& handler) { ws->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
+ ws->expires_never();
+
+ // The TLS handshake has no websocket-level timeout, so bound it like the TCP connect
+ ws->expires_after(timings.handshake);
+ if (const auto ec = run([&](auto&& handler) { ws->async_tls_handshake(endpoint.host, handler); })) {
+ throw beast::system_error{ec};
+ }
+ ws->expires_never();
+
+ websocket::stream_base::timeout timeouts{};
+ timeouts.handshake_timeout = timings.handshake;
+ timeouts.idle_timeout = timings.idle;
+ timeouts.keep_alive_pings = true;
+ ws->set_timeout(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);
+ }
+ });
std::string host_header = bracket_host_for_header(endpoint.host);
if (!endpoint.port.empty() && endpoint.port != (endpoint.secure ? MOONRAKER_DEFAULT_TLS_PORT : MOONRAKER_DEFAULT_PORT)) {
host_header += ":" + endpoint.port;
}
- ws.handshake(host_header, endpoint.target);
- ws.text(true);
+ 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
nlohmann::json identify;
@@ -2499,7 +2748,10 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, ConnectionSett
identify["params"]["type"] = "agent";
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
identify["id"] = 0;
- ws.write(identify.dump());
+ const std::string identify_payload = identify.dump();
+ if (const auto ec = run([&](auto&& handler) { ws->async_write(identify_payload, handler); })) {
+ throw beast::system_error{ec};
+ }
std::set subscribe_objects = {"print_stats", "virtual_sdcard"};
std::set available_objects;
@@ -2551,13 +2803,15 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, ConnectionSett
}
subscribe["params"]["objects"] = std::move(objects);
subscribe["id"] = 1;
- ws.write(subscribe.dump());
+ const std::string subscribe_payload = subscribe.dump();
+ if (const auto ec = run([&](auto&& handler) { ws->async_write(subscribe_payload, handler); })) {
+ throw beast::system_error{ec};
+ }
// Eager fetch so AMS data is available immediately after connecting,
// without waiting on the loop's own refresh clock below.
fetch_filament_info(dev_id, FilamentSyncMode::subscription);
- ams_last_fetch_ms.store(static_cast(
- std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()));
+ ams_last_fetch_ms.store(steady_now_ms());
// Read loop
while (!ws_stop.load()) {
@@ -2566,65 +2820,53 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, ConnectionSett
// here so the GUI-thread getters stay non-blocking.
refresh_webcam_info();
- ws.expires_after(std::chrono::seconds(2));
- std::string payload;
- std::string read_error;
- const auto read_result = ws.read(payload, read_error);
- if (read_result == MoonrakerWebsocket::ReadResult::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;
+ beast::flat_buffer buffer;
+ const auto ec = run([&](auto&& handler) { ws->async_read(buffer, handler); });
+ if (ws_stop.load()) {
+ break;
}
- if (read_result == MoonrakerWebsocket::ReadResult::closed) {
+ if (ec == websocket::error::closed) {
connection_lost = true;
break;
}
- if (read_result == MoonrakerWebsocket::ReadResult::error) {
- BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket read error: " << read_error;
+ if (ec) {
+ BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket read error: " << ec.message();
connection_lost = true;
break;
}
+
// AMS/filament refresh on its own clock: gated independently of
// ws_last_emit_ms so a steady telemetry stream can't starve it.
{
- 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_ams_ms = ams_last_fetch_ms.load();
if (last_ams_ms == 0 || now_ms - last_ams_ms >= AMS_REFRESH_INTERVAL_MS) {
fetch_filament_info(dev_id, FilamentSyncMode::subscription);
ams_last_fetch_ms.store(now_ms);
}
}
- handle_ws_message(dev_id, std::move(payload), connection);
- // Check if handle_ws_message triggered reconnection request`
+ 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()), connection);
+ // Check if handle_ws_message triggered reconnection request
if (ws_reconnect_requested.exchange(false)) {
connection_lost = true;
break;
}
}
-
- ws.close();
-
- // 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;
@@ -2635,21 +2877,13 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, ConnectionSett
break;
}
- // Exponential backoff before reconnection
- const 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) << ")";
- {
- // Interruptible so stop_status_stream() doesn't have to wait out a backoff of up to 32 s.
- std::unique_lock wait_lock(ws_wait_mutex);
- ws_wait_cv.wait_for(wait_lock, std::chrono::milliseconds(delay_ms), [this] { return ws_stop.load(); });
- }
+ // 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(std::string dev_id, std::string payload, ConnectionSettings connection)
@@ -2723,10 +2957,10 @@ void MoonrakerPrinterAgent::handle_ws_message(std::string dev_id, std::string pa
}
if (updated) {
+ // Uses the connection captured at stream start, not the mutable device_info
refresh_thumbnail_url(connection);
- 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
@@ -2747,6 +2981,24 @@ void MoonrakerPrinterAgent::handle_ws_message(std::string dev_id, std::string pa
}
}
+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()) {
@@ -3259,8 +3511,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, std::move(connection));
+ 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, std::move(connection));
+ }
#endif
// Success!
diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp
index bf1a919e75..76be599ba7 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
@@ -12,7 +13,6 @@
#include
#include
#include
-#include
#include
#include
@@ -119,6 +119,8 @@ public:
int set_on_local_message_fn(OnMessageFn fn) override;
int set_queue_on_main_fn(QueueOnMainFn fn) override;
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
+ // Pull-mode agent (on-demand filament sync)
+ FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
@@ -152,6 +154,16 @@ protected:
std::string ca_file;
} 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
@@ -186,6 +198,9 @@ protected:
MoonrakerDeviceInfo snapshot_device_info() const;
void configure_http(Http& http, const ConnectionSettings& connection) const;
static float parse_nozzle_diameter(const nlohmann::json& response);
+ // The connection is copied when the stream starts, so changes apply to the next start.
+ void start_status_stream(const std::string& dev_id, ConnectionSettings connection);
+ void stop_status_stream();
// State access for derived classes
mutable std::recursive_mutex state_mutex;
@@ -259,10 +274,11 @@ private:
MessageRouter make_message_router(const std::string& dev_id) const;
void dispatch_message(const std::string& dev_id, const std::string& payload);
- void start_status_stream(const std::string& dev_id, ConnectionSettings connection);
- void stop_status_stream();
- void run_status_stream(std::string dev_id, ConnectionSettings connection);
+ // 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, ConnectionSettings connection, StatusStreamTimings timings);
void handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection);
+ void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
void refresh_thumbnail_url(const ConnectionSettings& connection);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
@@ -329,16 +345,11 @@ private:
std::atomic ws_last_emit_ms{0};
std::thread ws_thread;
- // stop_status_stream() invokes ws_abort_io to wake a blocked synchronous
- // ws.read()/ws.write()/handshake in run_status_stream(): ws_stop is only
- // observed between reads, and Beast's expires_after() does not bound
- // synchronous operations.
- std::mutex ws_abort_mutex;
- std::function ws_abort_io; // guarded by ws_abort_mutex
-
- // Interrupts the exponential reconnect backoff in run_status_stream() when stopping.
- std::mutex ws_wait_mutex;
- std::condition_variable ws_wait_cv;
+ // Guards ws_thread and ws_abort. ws_abort posts to the stream's io_context, cancelling the
+ // reconnect wait and interrupting whatever connection attempt is running, so stop is prompt
+ // even when the stream thread is blocked on connect, TLS, or a read.
+ std::mutex ws_abort_mutex;
+ std::function ws_abort; // guarded by ws_abort_mutex
// AMS/filament refresh cadence, independent of telemetry dispatch so a steady
// stream of status updates can't starve it (ws_last_emit_ms is reset by those).
diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt
index 3a62bff2dd..a9f61e56ac 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_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_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..a81578cb4b
--- /dev/null
+++ b/tests/slic3rutils/test_moonraker_printer_agent.cpp
@@ -0,0 +1,636 @@
+#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 ConnectionSettings&, 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::ws_timings;
+
+ // Starts the stream the way a connection does, from a snapshot of the connection settings.
+ void start_stream(const std::string& base_url, bool use_ssl = false)
+ {
+ const std::string dev_id = "printer";
+ ConnectionSettings connection;
+ connection.dev_id = dev_id;
+ connection.base_url = base_url;
+ connection.use_ssl = use_ssl;
+ start_status_stream(dev_id, std::move(connection));
+ }
+
+ void stop_stream() { stop_status_stream(); }
+
+protected:
+ bool init_device_info(const PrinterConnectionParams& params) override
+ {
+ device_info = MoonrakerDeviceInfo{};
+ device_info.dev_id = params.dev_id;
+ device_info.dev_ip = params.host;
+ device_info.base_url = std::string(params.use_ssl ? "https://" : "http://") + params.host + ":" + params.port;
+ return true;
+ }
+
+ bool fetch_device_info(const ConnectionSettings&, 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
+ RejectsTls, // Records the first byte of the stream, which a TLS client sends as a handshake
+};
+
+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;
+ }
+
+ // First byte the stream sent, or -1 until it has sent one.
+ bool wait_for_first_byte()
+ {
+ std::unique_lock lock(m_mutex);
+ return m_changed.wait_for(lock, 10s, [&] { return m_first_byte >= 0; });
+ }
+
+ int first_byte()
+ {
+ std::lock_guard lock(m_mutex);
+ return m_first_byte;
+ }
+
+ // 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;
+ }
+
+ if (m_behavior == Behavior::RejectsTls) {
+ char first = 0;
+ beast::error_code ec;
+ socket.read_some(net::buffer(&first, 1), ec);
+ if (!ec) {
+ std::lock_guard lock(m_mutex);
+ m_first_byte = static_cast(first);
+ m_changed.notify_all();
+ }
+ 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;
+ int m_first_byte = -1;
+ 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_stream(printer.base_url()); }
+
+ // Runs the stream as a wss client; the printer must be served on the same port.
+ void start_secure() { agent.start_stream("https://" + printer.host() + ":" + printer.port(), true); }
+
+ // 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_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("A secure status stream opens with a TLS handshake", "[MoonrakerPrinterAgent][StatusStream]")
+{
+ StreamFixture f(Behavior::RejectsTls);
+ f.start_secure();
+ REQUIRE(f.printer.wait_for_first_byte());
+ // 0x16 is a TLS handshake record; a plaintext client would send "GET".
+ CHECK(f.printer.first_byte() == 0x16);
+ CHECK(f.stops_within(3s));
+}
+
+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_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));
+
+ // disconnect_printer joins the worker running the stalled query, so it is called aside and the
+ // printer is released to let that query return.
+ auto disconnecting = std::async(std::launch::async, [&] { 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));
+ disconnecting.wait();
+}
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;