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("\n

V/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;