diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..cd03768
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,38 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Python ${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version:
+ - "3.9"
+ - "3.13"
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Run local validation
+ run: python scripts/check_all.py
+
+ - name: Check pull request diff whitespace
+ if: github.event_name == 'pull_request'
+ run: git diff --check "origin/${{ github.base_ref }}...HEAD"
diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml
new file mode 100644
index 0000000..04f5fdb
--- /dev/null
+++ b/.github/workflows/compat.yml
@@ -0,0 +1,31 @@
+name: Klipper And Kalico Compatibility
+
+on:
+ schedule:
+ - cron: "17 4 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ firmware-contract:
+ name: Firmware contracts
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+
+ - name: Check repository
+ run: python scripts/check_all.py
+
+ - name: Check firmware contracts
+ run: python scripts/check_firmware_compat.py
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..4b0dd00
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,174 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Existing tag to draft, for example v1.2.0"
+ required: true
+ type: string
+ channel:
+ description: "Expected release channel"
+ required: true
+ default: "stable"
+ type: choice
+ options:
+ - stable
+ - beta
+
+permissions:
+ contents: read
+
+jobs:
+ validate-release-ref:
+ name: Validate release ref
+ runs-on: ubuntu-latest
+ outputs:
+ tag: ${{ steps.release.outputs.tag }}
+ version: ${{ steps.release.outputs.version }}
+ channel: ${{ steps.release.outputs.channel }}
+ prerelease: ${{ steps.release.outputs.prerelease }}
+ title: ${{ steps.release.outputs.title }}
+ env:
+ RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
+ RELEASE_CHANNEL: ${{ github.event_name == 'workflow_dispatch' && inputs.channel || '' }}
+
+ steps:
+ - name: Validate release metadata
+ id: release
+ run: |
+ stable_re='^v([0-9]+\.[0-9]+\.[0-9]+)$'
+ beta_re='^v([0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+)$'
+
+ if [[ "$RELEASE_TAG" =~ $stable_re ]]; then
+ version="${BASH_REMATCH[1]}"
+ channel="stable"
+ prerelease="false"
+ elif [[ "$RELEASE_TAG" =~ $beta_re ]]; then
+ version="${BASH_REMATCH[1]}"
+ channel="beta"
+ prerelease="true"
+ else
+ echo "invalid release tag '$RELEASE_TAG'; expected vX.Y.Z or vX.Y.Z-beta.N" >&2
+ exit 1
+ fi
+
+ if [ -n "$RELEASE_CHANNEL" ] \
+ && [ "$channel" != "$RELEASE_CHANNEL" ]; then
+ echo "tag $RELEASE_TAG is $channel, not $RELEASE_CHANNEL" >&2
+ exit 1
+ fi
+
+ {
+ echo "tag=$RELEASE_TAG"
+ echo "version=$version"
+ echo "channel=$channel"
+ echo "prerelease=$prerelease"
+ echo "title=$RELEASE_TAG"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Verify tag exists
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.release.outputs.tag }}
+ run: gh api --silent "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}"
+
+ validate-source:
+ name: Validate source and build archive
+ runs-on: ubuntu-latest
+ needs:
+ - validate-release-ref
+ env:
+ RELEASE_VERSION: ${{ needs.validate-release-ref.outputs.version }}
+
+ steps:
+ - name: Check out release tag
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # actions/checkout@v6.0.3
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: refs/tags/${{ needs.validate-release-ref.outputs.tag }}
+
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # actions/setup-python@v6.2.0
+ with:
+ python-version: "3.13"
+
+ - name: Run local validation
+ run: python scripts/check_all.py
+
+ - name: Build source archive
+ run: |
+ mkdir -p dist
+ archive="dist/klipper_z_calibration-${RELEASE_VERSION}.tar.gz"
+ git archive \
+ --format=tar.gz \
+ --prefix="klipper_z_calibration-${RELEASE_VERSION}/" \
+ --output="$archive" \
+ HEAD
+
+ - name: Upload release archive
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
+ with:
+ name: release-archive
+ path: dist/klipper_z_calibration-${{ env.RELEASE_VERSION }}.tar.gz
+ if-no-files-found: error
+ retention-days: 1
+
+ draft-release:
+ name: Draft release
+ runs-on: ubuntu-latest
+ needs:
+ - validate-release-ref
+ - validate-source
+ environment: release
+ permissions:
+ contents: write
+ env:
+ PRERELEASE: ${{ needs.validate-release-ref.outputs.prerelease }}
+ RELEASE_TAG: ${{ needs.validate-release-ref.outputs.tag }}
+ RELEASE_TITLE: ${{ needs.validate-release-ref.outputs.title }}
+
+ steps:
+ - name: Download release archive
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # actions/download-artifact@v8.0.0
+ with:
+ name: release-archive
+ path: dist
+
+ - name: Create or update draft GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
+ release_api="repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}"
+ release_id="$(gh api "$release_api" --jq '.id')"
+ is_draft="$(gh api "$release_api" --jq '.draft')"
+ if [ "$is_draft" != "true" ]; then
+ echo "Release $RELEASE_TAG already exists and is not a draft" >&2
+ exit 1
+ fi
+ gh api --method PATCH \
+ "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
+ -F draft=true \
+ -F prerelease="$PRERELEASE" \
+ -F name="$RELEASE_TITLE" >/dev/null
+ gh release upload "$RELEASE_TAG" dist/*.tar.gz --clobber
+ exit 0
+ fi
+
+ create_args=(
+ release create "$RELEASE_TAG"
+ dist/*.tar.gz
+ --draft
+ --verify-tag
+ --title "$RELEASE_TITLE"
+ --generate-notes
+ )
+ if [ "$PRERELEASE" = "true" ]; then
+ create_args+=(--prerelease)
+ fi
+ gh "${create_args[@]}"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f8b63b9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+__pycache__/
+*.py[cod]
+*$py.class
+
+.pytest_cache/
+.coverage
+htmlcov/
+
+.mypy_cache/
+.ruff_cache/
+
+.venv/
+venv/
+env/
+
+.idea/
+.vscode/
+*.swp
+*~
+.codex/
+.agents/
+.compat_repos/
+
+.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..47228f1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,140 @@
+# Agent Instructions
+
+This file is for automated coding agents working in this repository.
+Human contributor guidance is in `CONTRIBUTING.md`. Maintainer release steps
+are in `docs/maintainer-release.md`.
+
+## Project Rules
+
+`klipper_z_calibration` is a standalone Klipper/Kalico plugin for dockable
+contact probes.
+
+Keep these boundaries intact:
+
+- `z_calibration.py` is the Klipper plugin entrypoint.
+- `klipper_compat.py` isolates Klipper/Kalico API assumptions.
+- Only `z_calibration.py` is linked into Klipper/Kalico by `install.sh`.
+- Helper modules load from the repository checkout through the symlink target.
+- Do not add runtime Python modules unless the install model is intentionally
+ changed.
+- Preserve support for the old Kalico external plugin mechanism.
+
+Unsupported probe families are out of scope unless project policy changes:
+
+- BLTouch-style probes
+- Beacon-style probes
+- non-dockable or virtual probe implementations
+- virtual Z endstops for the calibration endstop
+
+The Wiki is the source of truth for full user configuration. Do not copy large
+Wiki sections into repository docs.
+
+## Compatibility Rules
+
+Put direct Klipper/Kalico implementation assumptions in `klipper_compat.py`.
+Examples include:
+
+- event and object lookup assumptions
+- homing/probing APIs
+- probe session APIs
+- bed mesh internals
+- toolhead status access
+- gcode offset APIs
+
+When adding or changing compatibility-sensitive behavior, add focused tests for
+the affected wrapper and update `scripts/check_klipper_contract.py` if a new
+upstream Klipper source contract is required.
+
+## Formatting
+
+This repository follows Klipper-style formatting via:
+
+```bash
+python3 scripts/check_whitespace.py
+```
+
+Requirements include:
+
+- UTF-8 encoded files
+- no trailing whitespace
+- no tabs, except where explicitly allowed
+- maximum line length of 80 characters for Python source
+- newline at end of file
+- no extra blank lines at end of file
+- no invalid control characters
+
+Keep diffs focused. Do not perform unrelated formatting-only changes.
+
+## Testing Expectations
+
+The goal is behavioral coverage, not just line coverage.
+
+Add or update tests for:
+
+- new behavior
+- bug fixes
+- compatibility changes
+- config parsing and validation
+- event and object lifecycle behavior
+- G-Code command behavior
+- probe session behavior
+- Moonraker updater config migration
+- release helper behavior
+
+Compatibility-sensitive paths should have explicit tests for feature detection,
+old/new Klipper behavior, Kalico-specific behavior, or Moonraker behavior as
+applicable.
+
+## Required Validation
+
+Before considering a task complete, run:
+
+```bash
+python3 scripts/check_all.py
+```
+
+This runs whitespace validation, shell syntax validation, compile checks, unit
+tests, and `git diff --check`.
+
+If release helper behavior changed, also run:
+
+```bash
+python3 scripts/check_release.py --tag v1.2.3 --channel stable
+python3 scripts/check_release.py --tag v1.2.3-beta.1 --channel beta
+```
+
+If Klipper API assumptions changed and a local Klipper checkout is available,
+run:
+
+```bash
+python3 scripts/check_klipper_contract.py --klipper-path ~/klipper
+```
+
+To clone or update ignored local Klipper/Kalico checkouts and run all firmware
+contract checks:
+
+```bash
+python3 scripts/check_firmware_compat.py
+```
+
+After the ignored checkouts exist, use the offline form when network access is
+not needed:
+
+```bash
+python3 scripts/check_firmware_compat.py --no-update
+```
+
+## Review Checklist
+
+Before finishing, review whether the change affects:
+
+- startup behavior
+- printer state transitions
+- configuration parsing or migration
+- Moonraker integration
+- Kalico compatibility
+- probe session cleanup
+- installer cleanup
+- release workflow behavior
+
+Document any remaining risks or assumptions in the final response.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..80b8e9a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,109 @@
+# Contributing
+
+Thanks for helping improve `klipper_z_calibration`. This project is a
+standalone Klipper/Kalico plugin for dockable contact probes.
+
+## Project Scope
+
+This repository supports:
+
+- stock Klipper installs through `klippy/extras`
+- Kalico installs through `klippy/plugins`
+- dockable contact probes
+- Moonraker Update Manager installs
+
+This repository does not support:
+
+- BLTouch-style probes
+- Beacon-style probes
+- non-dockable or virtual probe implementations
+- virtual Z endstops for the calibration endstop
+
+The Wiki remains the source of truth for complete user configuration. Avoid
+copying large Wiki sections into this repository.
+
+## Runtime Layout
+
+Keep the runtime layout simple:
+
+- `z_calibration.py` is the Klipper plugin entrypoint.
+- `klipper_compat.py` isolates compatibility-sensitive Klipper/Kalico API
+ assumptions.
+- Only `z_calibration.py` is linked into Klipper/Kalico by the installer.
+ Helper modules load from the repository checkout through the symlink target.
+
+Put direct Klipper/Kalico implementation assumptions in `klipper_compat.py`.
+Keep calibration behavior, config parsing, G-Code command handling, and runtime
+flow in `z_calibration.py`.
+
+## Code Changes
+
+- Keep behavior changes minimal and explicit.
+- Match the existing Klipper-style formatting.
+- Do not add new runtime modules unless the install model is intentionally
+ changed.
+- Do not add unsupported probe workarounds without first defining the support
+ policy and tests.
+- Preserve compatibility with the currently supported old Kalico plugin
+ mechanism.
+
+## Tests
+
+New behavior, bug fixes, and compatibility changes should include tests.
+
+Prioritize tests for:
+
+- config parsing and validation
+- event and object lifecycle behavior
+- G-Code command behavior
+- probe session behavior
+- Klipper/Kalico compatibility wrappers
+- Moonraker updater config migration
+- release helper behavior
+
+Compatibility-sensitive paths should be covered explicitly. Avoid relying only
+on broad happy-path tests.
+
+## Validation
+
+Run these checks before submitting a pull request:
+
+```bash
+python3 scripts/check_all.py
+```
+
+The check runner performs whitespace validation, shell syntax validation,
+compile checks, unit tests, and `git diff --check`.
+
+If you touch release helper behavior, also run:
+
+```bash
+python3 scripts/check_release.py --tag v1.2.3 --channel stable
+python3 scripts/check_release.py --tag v1.2.3-beta.1 --channel beta
+```
+
+If you touch Klipper API assumptions and have a local Klipper checkout, run:
+
+```bash
+python3 scripts/check_klipper_contract.py --klipper-path ~/klipper
+```
+
+To clone or update local Klipper/Kalico compatibility checkouts under the
+ignored `.compat_repos/` directory and run all firmware contract checks:
+
+```bash
+python3 scripts/check_firmware_compat.py
+```
+
+After those checkouts exist, rerun the same contract checks without network
+access:
+
+```bash
+python3 scripts/check_firmware_compat.py --no-update
+```
+
+## Releases
+
+Release publishing is maintainer-owned. Maintainers should follow:
+
+[docs/maintainer-release.md](docs/maintainer-release.md)
diff --git a/README.md b/README.md
index 855de23..3e6f883 100644
--- a/README.md
+++ b/README.md
@@ -1,54 +1,189 @@
-
+
+
+
+
+
Automatic Z-Calibration
-It's like automatically baby-stepping on your 3D printer before every print, and the first layer will
-always be perfect - no matter which nozzle or new flex-plate is being tested.
+ Automatic Z offset calibration for Klipper and Kalico printers using
+ dockable contact probes.
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-## Documentation
+## Overview
-Visit the [Wiki](https://github.com/protoloft/klipper_z_calibration/wiki) to view the full documentation for this Klipper plugin.
+`klipper_z_calibration` is a standalone Klipper/Kalico plugin that measures the
+relationship between a fixed Z endstop, a dockable probe, and the print surface,
+then applies the resulting Z offset. The primary runtime command is
+`CALIBRATE_Z`.
-The latest release notes are [here](https://github.com/protoloft/klipper_z_calibration/wiki/Changelog).
+The full user documentation remains in the
+[Wiki](https://github.com/protoloft/klipper_z_calibration/wiki). This README is
+only the quick project overview, installation entry point, and compatibility
+summary.
-:pushpin: **And remember:** The smaller the switch-offset, the further the
- nozzle is away from the bed! :wink:
+## Supported Setup
-## Further Resources
+Supported:
+
+- Klipper installations using `klippy/extras`
+- Kalico installations using the external plugin mechanism in `klippy/plugins`
+- Dockable contact probes such as Klicky-style probes
+- Moonraker Update Manager installs
+
+Not supported:
+
+- BLTouch-style probes
+- Beacon-style probes
+- Other non-dockable or virtual probe implementations
+- Virtual Z endstops for the Z calibration endstop
+
+Kalico users must enable plugin overrides:
+
+```ini
+[danger_options]
+allow_plugin_override: True
+```
+
+## Quick Install
+
+Clone the repository and run the installer as the printer user, not as root:
+
+```bash
+git clone https://github.com/protoloft/klipper_z_calibration.git
+cd klipper_z_calibration
+./install.sh
+```
+
+Useful installer options:
+
+```bash
+./install.sh -k ~/klipper
+./install.sh -m ~/printer_data/config/moonraker.conf
+./install.sh -n 2
+./install.sh -u
+```
+
+The installer links only `z_calibration.py` into Klipper or Kalico. Supporting
+Python modules are loaded from this repository checkout through the symlink
+target, so the checkout must remain in place.
+
+## Moonraker Updates
+
+The installer manages the `[update_manager z_calibration]` section in
+`moonraker.conf`.
+
+- New installations use `channel: stable`.
+- Existing sections without a `channel` are migrated to `channel: stable`
+ when the installer is rerun.
+- Existing explicit `stable`, `beta`, or `dev` channels are left unchanged.
+- `managed_services: klipper` is configured so updates restart Klipper.
+
+Moonraker updates do not run `install.sh`. Existing installs with no
+`channel` continue to use Moonraker's implicit `dev` channel until the user
+reruns `./install.sh` or manually adds `channel: stable` to the updater
+section.
-A great how-to video by Kapman: [https://youtu.be/oQYHFecsTto](https://youtu.be/oQYHFecsTto)
+Moonraker's modern default config path is
+`~/printer_data/config/moonraker.conf`. Use `install.sh -m ` for custom
+layouts.
+
+## Configuration Notes
+
+Use the Wiki for the full configuration reference:
+
+https://github.com/protoloft/klipper_z_calibration/wiki
+
+**The smaller the configured `switch_offset`, the farther the nozzle is from the
+bed.**
+
+Current releases expect the modern option names:
+
+- `offset_margins`
+- `safe_z_height`
+- `offset_gcode`
+- `error_gcode`
+
+The older `max_deviation` and `clearance` options are no longer supported.
+
+`offset_gcode` is an optional hook for toolchanger systems that need to apply
+the calculated Z adjustment through custom G-Code.
+
+`error_gcode` is an optional hook for reacting to `CALIBRATE_Z` failures.
+
+## Commands
+
+The plugin registers these G-Code commands:
+
+- `CALIBRATE_Z`: measure and apply the current Z offset.
+- `PROBE_Z_ACCURACY`: probe the fixed Z endstop repeatedly for repeatability
+ checks.
+- `CALCULATE_SWITCH_OFFSET`: calculate a switch offset from the current Z
+ position after calibration.
+
+Command parameters and complete configuration examples are documented in the
+[Wiki](https://github.com/protoloft/klipper_z_calibration/wiki).
+
+## Development And Releases
+
+Contributor guide:
+[CONTRIBUTING.md](CONTRIBUTING.md)
+
+Maintainer release process:
+[docs/maintainer-release.md](docs/maintainer-release.md)
+
+This repository includes unit tests, release validation helpers, GitHub Actions
+CI, and a scheduled Klipper/Kalico compatibility workflow. Critical Klipper and
+Kalico API assumptions are isolated in `klipper_compat.py`.
+
+Run the standard local validation suite with:
+
+```bash
+python3 scripts/check_all.py
+```
+
+## Further Resources
-### And if you are looking for an RRF version of this automatic z-offset calibration
+Kapman's how-to video:
+[https://youtu.be/oQYHFecsTto](https://youtu.be/oQYHFecsTto)
-You can find one [here](https://github.com/pRINTERnOODLE/Auto-Z-calibration-for-RRF-3.3-or-later-and-Klicky-Probe) from pRINTERnOODLE - This is really fantastic to see :tada:
+RRF version of automatic Z offset calibration:
+[Auto-Z-calibration-for-RRF-3.3-or-later-and-Klicky-Probe](https://github.com/pRINTERnOODLE/Auto-Z-calibration-for-RRF-3.3-or-later-and-Klicky-Probe)
-## Thanks for all your feedback and support!
+## Support
-And if you like my work and want to support me, you can do so here:
+If this project is useful to you, support is welcome:
[](https://ko-fi.com/X8X1C0DTD)
## Disclaimer
-You use it at your onw risk! I'm not responsible for any damage that might result. Although,
-this extension works rock solid for me and many others for years now. Always be careful
-and double check everything when configuring or working with your printer. And as always,
-never leave unattended while printing!
+Use this plugin at your own risk. You are responsible for validating your
+printer configuration and checking all motion paths before use. Never leave a
+printer unattended while printing.
diff --git a/docs/maintainer-release.md b/docs/maintainer-release.md
new file mode 100644
index 0000000..eed1487
--- /dev/null
+++ b/docs/maintainer-release.md
@@ -0,0 +1,347 @@
+# Maintainer Release Process
+
+This document is the release checklist for maintainers of
+`klipper_z_calibration`. It is intentionally procedural so a release can be
+prepared, verified, and published without making process decisions during the
+release.
+
+## Release Model
+
+1. Keep `master` releasable at all times.
+2. Use semantic version tags for all new releases.
+3. Use stable tags for production releases:
+
+ ```text
+ v1.2.0
+ v1.2.1
+ ```
+
+4. Use beta tags for prereleases:
+
+ ```text
+ v1.2.0-beta.1
+ v1.2.0-beta.2
+ ```
+
+5. Treat GitHub Releases as the public release record.
+6. Mark beta tags as GitHub prereleases.
+7. Mark stable tags as normal GitHub releases.
+8. Let the `Release` GitHub Actions workflow create draft releases.
+9. Publish draft releases manually after reviewing the generated notes.
+
+Moonraker supports `stable`, `beta`, and `dev` channels for `git_repo`
+extensions. If `channel` is omitted, Moonraker treats the extension as `dev`.
+This project should guide normal users to `stable`. Existing no-channel
+installs are not migrated by normal Moonraker updates, because Moonraker does
+not run `install.sh` during a `git_repo` update.
+
+## Moonraker Channel Policy
+
+New installer-created updater sections should use `channel: stable`:
+
+```ini
+[update_manager z_calibration]
+type: git_repo
+channel: stable
+path: /home/pi/klipper_z_calibration
+origin: https://github.com/protoloft/klipper_z_calibration.git
+managed_services: klipper
+```
+
+Existing updater sections should be handled as follows:
+
+1. If `[update_manager z_calibration]` has no `channel`, the installer should
+ migrate it to `channel: stable` when the installer is rerun.
+2. If the section already has `channel: stable`, leave it unchanged.
+3. If the section already has `channel: beta`, leave it unchanged.
+4. If the section already has `channel: dev`, leave it unchanged.
+
+This preserves explicit user intent while moving old implicit `dev` users to
+the safer stable release stream when they rerun the installer or edit
+`moonraker.conf`. Release notes alone are not a reliable migration mechanism,
+because many users update directly through Mainsail or Fluidd.
+
+## Pre-release Checklist
+
+Run this checklist before creating any beta or stable release.
+
+1. Confirm the working tree contains only intended release changes:
+
+ ```bash
+ git status --short
+ ```
+
+2. Confirm the target branch is `master`:
+
+ ```bash
+ git branch --show-current
+ ```
+
+3. Pull or fetch the latest remote state before tagging:
+
+ ```bash
+ git fetch origin --tags
+ ```
+
+4. Run the local validation suite:
+
+ ```bash
+ python3 scripts/check_all.py
+ python3 scripts/check_release.py --tag v1.2.3 --channel stable
+ python3 scripts/check_release.py --tag v1.2.3-beta.1 --channel beta
+ python3 scripts/check_klipper_contract.py --klipper-path ~/klipper
+ ```
+
+5. Confirm the GitHub Actions CI workflow passes on `master`.
+6. Confirm the Klipper Compatibility workflow is passing or review its latest
+ failure before release.
+7. Review installer behavior:
+ - stock Klipper installs link `z_calibration.py` to `klippy/extras`
+ - Kalico installs link `z_calibration.py` to `klippy/plugins`
+ - `klipper_compat.py` loads from the repository checkout
+ - Moonraker updater config uses the agreed channel policy
+ - no-channel Moonraker migration is documented as installer-scoped
+ - `managed_services: klipper` is present
+ - custom Moonraker paths use `install.sh -m `
+8. Review compatibility notes:
+ - standalone Klipper/Kalico plugin
+ - dockable contact probes only
+ - BLTouch, Beacon, and non-dockable virtual probes are unsupported
+ - `offset_margins` replaces the removed `max_deviation` option
+ - `safe_z_height` replaces the removed `clearance` option
+9. Update release notes with:
+ - compatibility changes
+ - migration notes
+ - installer behavior changes
+ - known limitations
+
+## Klipper Compatibility Monitoring
+
+Critical Klipper implementation assumptions are isolated in `klipper_compat.py`.
+The compatibility workflow validates the upstream source contracts that module
+uses.
+
+The `Klipper And Kalico Compatibility` workflow runs weekly and can be started
+manually.
+It checks:
+
+1. This repository's normal validation suite.
+2. The latest Klipper release tag.
+3. Klipper `master` as an early warning for upcoming breakage.
+4. Kalico `main` as an early warning for fork-specific breakage.
+
+Run the contract check manually against a local Klipper checkout:
+
+```bash
+python3 scripts/check_klipper_contract.py --klipper-path ~/klipper
+```
+
+To clone or update local Klipper/Kalico compatibility checkouts under the
+ignored `.compat_repos/` directory and run the same contract checks locally:
+
+```bash
+python3 scripts/check_firmware_compat.py
+```
+
+After the checkouts exist, rerun without fetching:
+
+```bash
+python3 scripts/check_firmware_compat.py --no-update
+```
+
+If the latest-release lane fails, treat it as a release blocker. If the
+`master` lane fails but the latest-release lane passes, open an issue and fix
+the compatibility layer before the upstream change reaches a Klipper release.
+
+## Beta Release Steps
+
+Use a beta release when a change needs testing on real printers before being
+promoted to stable.
+
+1. Choose the next beta version.
+
+ Example:
+
+ ```text
+ v1.2.0-beta.1
+ ```
+
+2. Run the pre-release checklist.
+3. Create and push the beta tag:
+
+ ```bash
+ git tag -a v1.2.0-beta.1 -m "v1.2.0-beta.1"
+ git push origin v1.2.0-beta.1
+ ```
+
+4. Wait for the `Release` workflow to pass on the tag.
+5. Open the draft GitHub Release created by the workflow.
+6. Confirm the draft is marked as a prerelease.
+7. Review and edit the generated release notes.
+8. Include tester guidance:
+ - configure Moonraker with `channel: beta`
+ - restart Moonraker after changing `moonraker.conf`
+ - update through Moonraker Update Manager
+ - report printer model, Klipper/Kalico commit, and probe type with issues
+9. Include rollback guidance:
+ - use Moonraker Update Manager rollback if available
+ - or switch back to `channel: stable`
+10. Publish the GitHub Release manually.
+11. Verify from a beta-channel install that Moonraker detects the prerelease.
+
+## Stable Release Steps
+
+Use a stable release for production-ready changes.
+
+1. Choose the next stable version.
+
+ Examples:
+
+ ```text
+ v1.2.0
+ v1.2.1
+ ```
+
+2. Run the pre-release checklist.
+3. If promoting a beta, confirm the stable tag points at the validated commit.
+4. Create and push the stable tag:
+
+ ```bash
+ git tag -a v1.2.0 -m "v1.2.0"
+ git push origin v1.2.0
+ ```
+
+5. Wait for the `Release` workflow to pass on the tag.
+6. Open the draft GitHub Release created by the workflow.
+7. Confirm the draft is not marked as a prerelease.
+8. Review and edit the generated release notes.
+9. Include release notes:
+ - user-visible changes
+ - compatibility changes
+ - no-channel Moonraker migration guidance
+ - exact `channel: stable` updater config snippet
+ - Moonraker restart instruction after editing `moonraker.conf`
+ - installer migration notes that explain `install.sh` must be rerun
+ - manual verification performed
+10. Publish the GitHub Release manually.
+11. Verify from a stable-channel install that Moonraker detects the release.
+
+## Post-release Verification
+
+After publishing, verify the release from user-like environments.
+
+1. Fresh stock Klipper install:
+ - run `install.sh`
+ - confirm `z_calibration.py` links into `klippy/extras`
+ - confirm no new `klipper_compat.py` link is created in `klippy/extras`
+ - confirm Moonraker updater section exists
+ - confirm Moonraker Update Manager shows the new version
+ - confirm custom Moonraker config paths work with `install.sh -m `
+2. Fresh Kalico install:
+ - run `install.sh`
+ - confirm `z_calibration.py` links into `klippy/plugins`
+ - confirm no new `klipper_compat.py` link is created in `klippy/plugins`
+ - confirm `allow_plugin_override` instructions are shown
+3. Existing no-channel Moonraker config:
+ - rerun `install.sh`
+ - confirm the installer migrates the section to `channel: stable`
+4. Existing no-channel Moonraker config through normal Moonraker update:
+ - update through Moonraker without rerunning `install.sh`
+ - confirm the section is not automatically migrated
+5. Existing explicit-channel Moonraker config:
+ - confirm `channel: stable` is unchanged
+ - confirm `channel: beta` is unchanged
+ - confirm `channel: dev` is unchanged
+6. Runtime smoke test:
+ - Klipper starts successfully
+ - `[z_calibration]` loads
+ - `CALIBRATE_Z` is registered
+ - status object exposes `last_query` and `last_z_offset`
+
+## Hotfix Process
+
+Use a hotfix for critical compatibility or safety fixes.
+
+1. Identify the latest stable release tag.
+2. Create the fix from `master` if it is releasable.
+3. If `master` contains unrelated risky changes, branch from the latest stable
+ tag instead.
+4. Apply only the minimal fix and related tests.
+5. Run the full pre-release checklist.
+6. Publish a patch release.
+
+Example:
+
+```text
+v1.2.1
+```
+
+7. Clearly mark the release as a hotfix in the GitHub Release notes.
+
+## Release Notes Template
+
+Use this structure for GitHub Release notes:
+
+```markdown
+## Summary
+
+- Short description of the release.
+
+## Changes
+
+- User-visible change.
+- Compatibility fix.
+- Installer or Moonraker behavior change.
+
+## Compatibility
+
+- Klipper:
+- Kalico:
+- Moonraker:
+- Supported probes:
+
+## Migration Notes
+
+- Required user action, if any.
+
+## Validation
+
+- CI passed.
+- Local validation commands passed.
+- Manual install/update checks performed.
+```
+
+## Maintainer Command Reference
+
+Run all validation:
+
+```bash
+python3 scripts/check_whitespace.py
+bash -n install.sh
+python3 scripts/check_release.py --tag v1.2.3 --channel stable
+python3 scripts/check_release.py --tag v1.2.3-beta.1 --channel beta
+python3 scripts/check_klipper_contract.py --klipper-path ~/klipper
+python3 -m compileall .
+python3 -m unittest discover -s tests -v
+git diff --check
+```
+
+List recent tags:
+
+```bash
+git tag --sort=-version:refname | head -20
+```
+
+Create an annotated stable tag:
+
+```bash
+git tag -a v1.2.0 -m "v1.2.0"
+git push origin v1.2.0
+```
+
+Create an annotated beta tag:
+
+```bash
+git tag -a v1.2.0-beta.1 -m "v1.2.0-beta.1"
+git push origin v1.2.0-beta.1
+```
diff --git a/install.sh b/install.sh
index 4bd0eae..2fec7e1 100755
--- a/install.sh
+++ b/install.sh
@@ -1,18 +1,69 @@
#!/bin/bash
+# Install, update, or uninstall the z_calibration Klipper/Kalico plugin.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
SRCDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/ && pwd )"
KLIPPER_PATH="${HOME}/klipper"
-SYSTEMDDIR="/etc/systemd/system"
MOONRAKER_CONFIG="${HOME}/printer_data/config/moonraker.conf"
MOONRAKER_FALLBACK="${HOME}/klipper_config/moonraker.conf"
+MOONRAKER_CONFIG_CUSTOM=0
NUM_INSTALLS=0
+NUM_INSTALLS_CUSTOM=0
# Force script to exit if an error occurs
set -e
+set_install_paths()
+{
+ KALICO_PLUGIN_DIR="${KLIPPER_PATH}/klippy/plugins"
+ KALICO_PLUGIN_FILE="${KALICO_PLUGIN_DIR}/z_calibration.py"
+ KALICO_COMPAT_FILE="${KALICO_PLUGIN_DIR}/klipper_compat.py"
+ KLIPPER_EXTRA_FILE="${KLIPPER_PATH}/klippy/extras/z_calibration.py"
+ KLIPPER_COMPAT_FILE="${KLIPPER_PATH}/klippy/extras/klipper_compat.py"
+}
+
+is_repo_link()
+{
+ link_path="$1"
+ target_path="$2"
+ [ -L "$link_path" ] && [ "$(readlink "$link_path")" = "$target_path" ]
+}
+
+remove_repo_link()
+{
+ link_path="$1"
+ target_path="$2"
+ if is_repo_link "$link_path" "$target_path"; then
+ rm -f "$link_path"
+ fi
+}
+
+remove_file_if_present()
+{
+ file_path="$1"
+ if [ -e "$file_path" ] || [ -L "$file_path" ]; then
+ rm -f "$file_path"
+ fi
+}
+
+validate_num_installs()
+{
+ if [ "$NUM_INSTALLS_CUSTOM" -eq 0 ]; then
+ return
+ fi
+ if [[ ! "$NUM_INSTALLS" =~ ^[1-9][0-9]*$ ]]; then
+ echo "Error: -n must be a positive integer"
+ exit -1
+ fi
+}
+
# Step 1: Check for root user
verify_ready()
{
+ validate_num_installs
# check for root user
if [ "$EUID" -eq 0 ]; then
echo "This script must not run as root"
@@ -20,14 +71,9 @@ verify_ready()
fi
# output used number of installs
if [[ $NUM_INSTALLS == 0 ]]; then
- echo "Defaulted to one klipper install, if more than one instance, use -n"
+ echo "Defaulted to one klipper install, if more than one instance, use -n"
else
- echo "Number of Installs Selected: $NUM_INSTALLS"
- fi
- # Fall back to old config
- if [ ! -f "$MOONRAKER_CONFIG" ]; then
- echo "${MOONRAKER_CONFIG} does not exist. Falling back to ${MOONRAKER_FALLBACK}"
- MOONRAKER_CONFIG="$MOONRAKER_FALLBACK"
+ echo "Number of Installs Selected: $NUM_INSTALLS"
fi
}
@@ -42,74 +88,97 @@ check_klipper()
exit -1
fi
else
- for (( klip = 1; klip<=$NUM_INSTALLS; klip++ )); do
- if [ "$(sudo systemctl list-units --full -all -t service --no-legend | grep -F "klipper-$klip.service")" ]; then
- echo "klipper-$klip.service found!"
- else
- echo "klipper-$klip.service NOT found, please ensure you've entered the correct number of klipper instances you're running!"
- exit -1
- fi
- done
- fi
+ for (( klip = 1; klip<=$NUM_INSTALLS; klip++ )); do
+ if [ "$(sudo systemctl list-units --full -all -t service --no-legend | grep -F "klipper-$klip.service")" ]; then
+ echo "klipper-$klip.service found!"
+ else
+ echo "klipper-$klip.service NOT found, please ensure you've entered the correct number of klipper instances you're running!"
+ exit -1
+ fi
+ done
+ fi
+}
+
+resolve_moonraker_config()
+{
+ if [ -f "$MOONRAKER_CONFIG" ]; then
+ echo "Moonraker configuration found at ${MOONRAKER_CONFIG}"
+ return
+ fi
+ if [ "$MOONRAKER_CONFIG_CUSTOM" -eq 0 ] \
+ && [ -f "$MOONRAKER_FALLBACK" ]; then
+ echo "${MOONRAKER_CONFIG} does not exist. Falling back to ${MOONRAKER_FALLBACK}"
+ MOONRAKER_CONFIG="$MOONRAKER_FALLBACK"
+ echo "Moonraker configuration found at ${MOONRAKER_CONFIG}"
+ return
+ fi
+ if [ "$MOONRAKER_CONFIG_CUSTOM" -eq 0 ]; then
+ echo "Error: Moonraker configuration not found: ${MOONRAKER_CONFIG} or ${MOONRAKER_FALLBACK}. Exiting.."
+ else
+ echo "Error: Moonraker configuration not found: ${MOONRAKER_CONFIG}. Exiting.."
+ fi
+ exit -1
}
# Step 3: Check folders
-check_requirements()
+check_klipper_path()
{
if [ ! -d "${KLIPPER_PATH}/klippy/extras/" ]; then
echo "Error: Klipper not found in directory: ${KLIPPER_PATH}. Exiting.."
exit -1
fi
echo "Klipper found at ${KLIPPER_PATH}"
+}
- if [ ! -f "$MOONRAKER_CONFIG" ]; then
- echo "Error: Moonraker configuration not found: ${MOONRAKER_CONFIG}. Exiting.."
- exit -1
- fi
- echo "Moonraker configuration found at ${MOONRAKER_CONFIG}"
+check_requirements()
+{
+ check_klipper_path
+ resolve_moonraker_config
}
# Step 4: Link extension to Klipper
-link_extension()
+link_kalico_extension()
{
- echo -n "Linking extension to Klipper... "
- ln -sf "${SRCDIR}/z_calibration.py" "${KLIPPER_PATH}/klippy/extras/z_calibration.py"
+ echo -n "Linking extension to Kalico plugins... "
+ ln -sf "${SRCDIR}/z_calibration.py" "$KALICO_PLUGIN_FILE"
+ remove_repo_link "$KLIPPER_EXTRA_FILE" "${SRCDIR}/z_calibration.py"
+ remove_repo_link "$KLIPPER_COMPAT_FILE" "${SRCDIR}/klipper_compat.py"
+ remove_repo_link "$KALICO_COMPAT_FILE" "${SRCDIR}/klipper_compat.py"
+ remove_file_if_present "${KLIPPER_PATH}/klippy/extras/klipper_compat.pyc"
+ remove_file_if_present "${KALICO_PLUGIN_DIR}/klipper_compat.pyc"
echo "[OK]"
+ echo "Kalico users must enable:"
+ echo " [danger_options]"
+ echo " allow_plugin_override: True"
}
-# Step 5: Remove old dummy system service
-remove_service()
+link_klipper_extension()
{
- SERVICE_FILE="${SYSTEMDDIR}/z_calibration.service"
- if [ -f "$SERVICE_FILE" ]; then
- echo -n "Removing system service... "
- sudo service z_calibration stop
- sudo systemctl disable z_calibration.service
- sudo rm "$SERVICE_FILE"
- echo "[OK]"
- fi
- OLD_SERVICE_FILE="${SYSTEMDDIR}/klipper_z_calibration.service"
- if [ -f "$OLD_SERVICE_FILE" ]; then
- echo -n "Removing old system service... "
- sudo service klipper_z_calibration stop
- sudo systemctl disable klipper_z_calibration.service
- sudo rm "$OLD_SERVICE_FILE"
- echo "[OK]"
+ echo -n "Linking extension to Klipper extras... "
+ ln -sf "${SRCDIR}/z_calibration.py" "$KLIPPER_EXTRA_FILE"
+ remove_repo_link "$KLIPPER_COMPAT_FILE" "${SRCDIR}/klipper_compat.py"
+ remove_file_if_present "${KLIPPER_PATH}/klippy/extras/klipper_compat.pyc"
+ echo "[OK]"
+}
+
+link_extension()
+{
+ if [ -d "$KALICO_PLUGIN_DIR" ]; then
+ link_kalico_extension
+ return
fi
+ link_klipper_extension
}
-# Step 6: Add updater to moonraker.conf
+# Step 5: Add updater to moonraker.conf
add_updater()
{
echo -n "Adding update manager to moonraker.conf... "
- update_section=$(grep -c '\[update_manager[a-z ]* z_calibration\]' $MOONRAKER_CONFIG || true)
- if [ "$update_section" -eq 0 ]; then
- echo -e "\n[update_manager z_calibration]" >> "$MOONRAKER_CONFIG"
- echo "type: git_repo" >> "$MOONRAKER_CONFIG"
- echo "path: ${SRCDIR}" >> "$MOONRAKER_CONFIG"
- echo "origin: https://github.com/protoloft/klipper_z_calibration.git" >> "$MOONRAKER_CONFIG"
- echo "managed_services: klipper" >> "$MOONRAKER_CONFIG"
- echo -e "\n" >> "$MOONRAKER_CONFIG"
+ update_result=$(python3 \
+ "${SRCDIR}/scripts/update_moonraker.py" \
+ "$MOONRAKER_CONFIG" \
+ "$SRCDIR")
+ if [ "$update_result" = "changed" ]; then
echo "[OK]"
echo -n "Restarting Moonraker... "
@@ -120,7 +189,7 @@ add_updater()
fi
}
-# Step 7: Restarting Klipper
+# Step 6: Restarting Klipper
restart_klipper()
{
if [[ $NUM_INSTALLS == 0 ]]; then
@@ -128,25 +197,38 @@ restart_klipper()
sudo systemctl restart klipper
echo "[OK]"
else
- for (( klip = 1; klip<=$NUM_INSTALLS; klip++)); do
+ for (( klip = 1; klip<=$NUM_INSTALLS; klip++)); do
echo -n "Restarting Klipper-$klip... "
sudo systemctl restart klipper-$klip
echo "[OK]"
- done
+ done
fi
}
uinstall()
{
- if [ -f "${KLIPPER_PATH}/klippy/extras/z_calibration.py" ]; then
+ if is_repo_link "$KALICO_PLUGIN_FILE" "${SRCDIR}/z_calibration.py" \
+ || is_repo_link "$KALICO_COMPAT_FILE" "${SRCDIR}/klipper_compat.py" \
+ || is_repo_link "$KLIPPER_EXTRA_FILE" "${SRCDIR}/z_calibration.py" \
+ || is_repo_link "$KLIPPER_COMPAT_FILE" "${SRCDIR}/klipper_compat.py"; then
echo -n "Uninstalling z_calibration... "
- rm -f "${KLIPPER_PATH}/klippy/extras/z_calibration.py"
- rm -f "${KLIPPER_PATH}/klippy/extras/z_calibration.pyc"
+ remove_repo_link \
+ "$KALICO_PLUGIN_FILE" "${SRCDIR}/z_calibration.py"
+ remove_repo_link \
+ "$KALICO_COMPAT_FILE" "${SRCDIR}/klipper_compat.py"
+ remove_file_if_present "${KALICO_PLUGIN_DIR}/z_calibration.pyc"
+ remove_file_if_present "${KALICO_PLUGIN_DIR}/klipper_compat.pyc"
+ remove_repo_link \
+ "$KLIPPER_EXTRA_FILE" "${SRCDIR}/z_calibration.py"
+ remove_repo_link \
+ "$KLIPPER_COMPAT_FILE" "${SRCDIR}/klipper_compat.py"
+ remove_file_if_present "${KLIPPER_PATH}/klippy/extras/z_calibration.pyc"
+ remove_file_if_present "${KLIPPER_PATH}/klippy/extras/klipper_compat.pyc"
echo "[OK]"
echo "You can now remove the \"[update_manager z_calibration]\" section in your moonraker.conf and delete this directory."
echo "You also need to remove the \"[z_calibration]\" section in your Klipper configuration..."
else
- echo -n "${KLIPPER_PATH}/klippy/extras/z_calibration.py not found. Is it installed? "
+ echo -n "z_calibration.py not found. Is it installed? "
echo "[FAILED]"
fi
}
@@ -158,31 +240,38 @@ usage()
}
# Command parsing
-while getopts ":k:m:n:uh" OPTION; do
- case "$OPTION" in
- k) KLIPPER_PATH="$OPTARG" ;;
- m) MOONRAKER_CONFIG="$OPTARG" ;;
- n) NUM_INSTALLS="$OPTARG" ;;
- u) UNINSTALL=1 ;;
- h | ?) usage ;;
- esac
-done
-
-# Fall back to old config
-if [ ! -f "$MOONRAKER_CONFIG" ]; then
- echo "${MOONRAKER_CONFIG} does not exist. Falling back to ${MOONRAKER_FALLBACK}"
- MOONRAKER_CONFIG="$MOONRAKER_FALLBACK"
-fi
+main()
+{
+ OPTIND=1
+ UNINSTALL=""
+ MOONRAKER_CONFIG_CUSTOM=0
+ NUM_INSTALLS_CUSTOM=0
+ while getopts ":k:m:n:uh" OPTION; do
+ case "$OPTION" in
+ k) KLIPPER_PATH="$OPTARG" ;;
+ m) MOONRAKER_CONFIG="$OPTARG"
+ MOONRAKER_CONFIG_CUSTOM=1 ;;
+ n) NUM_INSTALLS="$OPTARG"
+ NUM_INSTALLS_CUSTOM=1 ;;
+ u) UNINSTALL=1 ;;
+ h | ?) usage ;;
+ esac
+ done
+
+ set_install_paths
+ verify_ready
+ check_klipper
+ if [ -z "$UNINSTALL" ]; then
+ check_requirements
+ link_extension
+ add_updater
+ else
+ check_klipper_path
+ uinstall
+ fi
+ restart_klipper
+}
-# Run steps
-verify_ready
-check_klipper
-check_requirements
-remove_service
-if [ ! $UNINSTALL ]; then
- link_extension
- add_updater
-else
- uinstall
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+ main "$@"
fi
-restart_klipper
diff --git a/klipper_compat.py b/klipper_compat.py
new file mode 100644
index 0000000..c2df63b
--- /dev/null
+++ b/klipper_compat.py
@@ -0,0 +1,549 @@
+# Compatibility helpers and runtime contracts for Klipper/Kalico APIs.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+from mcu import MCU_endstop
+
+
+# Objects passed to homing.probing_move() need the MCU endstop interface, not
+# just the query_endstop() surface. Klipper has wrapped probe endstops before,
+# so keep this downstream contract explicit and shared by all probe targets.
+_PROBING_ENDSTOP_METHODS = [
+ 'get_steppers',
+ 'home_start',
+ 'home_wait',
+ 'query_endstop',
+]
+
+
+def _missing_probing_endstop_methods(endstop):
+ """Return MCU endstop methods missing from a probing target."""
+ return [name for name in _PROBING_ENDSTOP_METHODS
+ if not callable(getattr(endstop, name, None))]
+
+
+def _resolve_legacy_probe_endstop(probe):
+ """Resolve direct or wrapped probe MCU endstop objects."""
+ probe_endstop = getattr(probe, 'mcu_probe', None)
+ if probe_endstop is None:
+ return None
+ if not _missing_probing_endstop_methods(probe_endstop):
+ return probe_endstop
+ # Newer ProbeEndstopWrapper objects may retain query_endstop() while
+ # nesting the MCU endstop needed by homing.probing_move().
+ return getattr(probe_endstop, 'mcu_endstop', None)
+
+
+class PrinterObjectCompat:
+ """Centralizes Klipper object lookup assumptions."""
+
+ def __init__(self, printer):
+ self.printer = printer
+
+ def lookup_gcode(self):
+ """Return the required gcode object."""
+ return self.printer.lookup_object('gcode')
+
+ def lookup_gcode_move(self):
+ """Return the required gcode_move object."""
+ return self.printer.lookup_object('gcode_move')
+
+ def lookup_homing(self):
+ """Return the required homing object."""
+ return self.printer.lookup_object('homing')
+
+ def lookup_toolhead(self):
+ """Return the required toolhead object."""
+ return self.printer.lookup_object('toolhead')
+
+ def lookup_probe(self):
+ """Return the required probe object."""
+ return self.printer.lookup_object('probe')
+
+ def lookup_optional_probe(self):
+ """Return the probe object when one is configured."""
+ return self.printer.lookup_object('probe', default=None)
+
+ def lookup_safe_z_home(self):
+ """Return safe_z_home when present."""
+ return self.printer.lookup_object('safe_z_home', default=None)
+
+ def lookup_bed_mesh(self):
+ """Return bed_mesh when present."""
+ return self.printer.lookup_object('bed_mesh', default=None)
+
+ def load_gcode_macro(self, config):
+ """Load Klipper's gcode_macro helper object."""
+ return self.printer.load_object(config, 'gcode_macro')
+
+ def load_query_endstops(self, config):
+ """Load Klipper's query_endstops helper object."""
+ return self.printer.load_object(config, 'query_endstops')
+
+
+class RuntimeContractValidator:
+ """Validates live Klipper objects before calibration can run."""
+
+ # Runtime validation is deliberately side-effect free: no moves, no
+ # endstop queries, and no probe session start. It catches live object shape
+ # mismatches early, while behavior inside created sessions/results remains
+ # covered by focused tests and the source contract checker.
+ def __init__(self, printer, probe, section_name, z_endstop=None,
+ offset_gcode=None, error_gcode=None):
+ self.printer = printer
+ self.probe = probe
+ self.section_name = section_name
+ self.z_endstop = z_endstop
+ self.offset_gcode = offset_gcode
+ self.error_gcode = error_gcode
+ self.objects = PrinterObjectCompat(printer)
+
+ def validate(self):
+ """Run all startup runtime compatibility checks."""
+ self._validate_homing_probing_move()
+ self._validate_z_endstop_probe_target()
+ self._validate_toolhead_motion_status()
+ if self.offset_gcode is None:
+ self._validate_gcode_offset_command()
+ else:
+ self._validate_gcode_template(self.offset_gcode,
+ 'offset_gcode_template')
+ if self.error_gcode is not None:
+ self._validate_gcode_template(self.error_gcode,
+ 'error_gcode_template')
+ self._validate_probe_defaults()
+ self._validate_probe_execution_profile()
+ self._validate_legacy_probe_mcu_endstop()
+ self._validate_probe_endstop_query()
+
+ def _validate_homing_probing_move(self):
+ """Ensure the homing object still exposes probing_move()."""
+ topic = 'homing_probing_move'
+ homing = self._lookup(topic, self.objects.lookup_homing)
+ self._require_callable(homing, 'probing_move', topic)
+
+ def _validate_z_endstop_probe_target(self):
+ """Ensure the calibration endstop can be passed to probing_move()."""
+ if self.z_endstop is None:
+ return
+ # The Z endstop is always passed into homing.probing_move(), so it must
+ # satisfy the downstream probing target contract at startup.
+ self._require_probing_endstop(self.z_endstop,
+ 'z_endstop_probe_target')
+
+ def _validate_toolhead_motion_status(self):
+ """Ensure toolhead movement and status methods are available."""
+ topic = 'toolhead_motion_status'
+ toolhead = self._lookup(topic, self.objects.lookup_toolhead)
+ for attr in ['get_position', 'manual_move', 'get_last_move_time',
+ 'get_status']:
+ self._require_callable(toolhead, attr, topic)
+
+ def _validate_gcode_offset_command(self):
+ """Ensure G-Code offset commands can still be synthesized."""
+ topic = 'gcode_offset_command'
+ gcode = self._lookup(topic, self.objects.lookup_gcode)
+ gcode_move = self._lookup(topic, self.objects.lookup_gcode_move)
+ self._require_callable(gcode, 'create_gcode_command', topic)
+ self._require_callable(gcode_move, 'cmd_SET_GCODE_OFFSET', topic)
+
+ def _validate_gcode_template(self, template, topic):
+ """Ensure a configured G-Code template can receive params."""
+ for attr in ['create_template_context', 'run_gcode_from_command']:
+ self._require_callable(template, attr, topic)
+
+ def _validate_probe_defaults(self):
+ """Ensure probe defaults can be read from a supported API shape."""
+ topic = 'probe_defaults'
+ legacy_attrs = [
+ 'sample_count',
+ 'samples_tolerance',
+ 'samples_retries',
+ 'lift_speed',
+ 'samples_result',
+ 'z_offset',
+ ]
+ if all(hasattr(self.probe, attr) for attr in legacy_attrs):
+ return
+ if (self._has_callable(self.probe, 'get_probe_params')
+ and self._has_callable(self.probe, 'get_offsets')):
+ return
+ self._fail(topic, 'probe defaults API is not supported')
+
+ def _validate_probe_execution_profile(self):
+ """Ensure the probe exposes one supported probing profile."""
+ topic = 'probe_execution_profile'
+ if self._has_callable(self.probe, 'start_probe_session'):
+ return
+ if (self._has_callable(self.probe, 'multi_probe_begin')
+ and self._has_callable(self.probe, 'multi_probe_end')):
+ return
+ session = getattr(self.probe, 'probe_session', None)
+ if (session is not None
+ and self._has_callable(session, 'start_probe_session')
+ and self._has_callable(session, 'end_probe_session')):
+ return
+ self._fail(topic, 'probe execution API is not supported')
+
+ def _validate_legacy_probe_mcu_endstop(self):
+ """Ensure legacy probe fallback has a usable MCU endstop."""
+ if self._has_callable(self.probe, 'start_probe_session'):
+ return
+ topic = 'legacy_probe_mcu_endstop'
+ # Legacy probing falls back to passing the probe endstop into
+ # homing.probing_move(). Validate the resolved object, not only
+ # probe.mcu_probe, because Klipper may wrap it.
+ probe_endstop = _resolve_legacy_probe_endstop(self.probe)
+ if probe_endstop is None:
+ self._fail(topic, 'probe MCU endstop is not available')
+ self._require_probing_endstop(probe_endstop, topic)
+
+ def _validate_probe_endstop_query(self):
+ """Ensure probe attach checks can query an endstop."""
+ topic = 'probe_endstop_query'
+ # Query support is separate from probing_move support. A wrapper can
+ # expose query_endstop() while lacking the MCU endstop methods.
+ for candidate in self._query_endstop_candidates():
+ if self._has_callable(candidate, 'query_endstop'):
+ return
+ self._fail(topic, 'probe endstop query API is not supported')
+
+ def _query_endstop_candidates(self):
+ """Return probe objects that may expose query_endstop()."""
+ probe_endstop = getattr(self.probe, 'mcu_probe', None)
+ candidates = [self.probe, probe_endstop]
+ if probe_endstop is not None:
+ candidates.append(getattr(probe_endstop, 'mcu_endstop', None))
+ return [candidate for candidate in candidates if candidate is not None]
+
+ def _lookup(self, topic, lookup_func):
+ """Translate lookup failures into named contract failures."""
+ try:
+ return lookup_func()
+ except Exception as err:
+ self._fail(topic, 'object lookup failed: %s' % (err,))
+
+ def _require_callable(self, obj, attr, topic):
+ """Fail a contract when an expected method is unavailable."""
+ if not self._has_callable(obj, attr):
+ self._fail(topic, '%s.%s is not callable'
+ % (obj.__class__.__name__, attr))
+
+ def _require_probing_endstop(self, endstop, topic):
+ """Fail when an object cannot be passed to probing_move()."""
+ missing = _missing_probing_endstop_methods(endstop)
+ if missing:
+ self._fail(topic, 'missing %s' % (', '.join(missing),))
+
+ def _has_callable(self, obj, attr):
+ """Return whether an object exposes a callable attribute."""
+ return callable(getattr(obj, attr, None))
+
+ def _fail(self, topic, detail):
+ """Raise a Klipper config error for a named runtime contract."""
+ message = "Klipper compatibility check failed for %s: %s" % (
+ self.section_name, topic)
+ if detail:
+ message += " (%s)" % (detail,)
+ raise self.printer.config_error(message)
+
+
+def validate_runtime_contract(printer, probe, section_name, z_endstop=None,
+ offset_gcode=None, error_gcode=None):
+ """Validate live Klipper/Kalico objects during plugin startup."""
+ RuntimeContractValidator(printer, probe, section_name,
+ z_endstop, offset_gcode, error_gcode).validate()
+
+
+class EndstopWrapper:
+ """Forwards the MCU endstop surface expected by probing_move()."""
+
+ def __init__(self, endstop):
+ self.mcu_endstop = endstop
+
+ def get_mcu(self):
+ """Forward get_mcu() to the wrapped MCU endstop."""
+ return self.mcu_endstop.get_mcu()
+
+ def add_stepper(self, stepper):
+ """Forward add_stepper() to the wrapped MCU endstop."""
+ return self.mcu_endstop.add_stepper(stepper)
+
+ def get_steppers(self):
+ """Forward get_steppers() to the wrapped MCU endstop."""
+ return self.mcu_endstop.get_steppers()
+
+ def home_start(self, *args, **kwargs):
+ """Forward home_start() to the wrapped MCU endstop."""
+ return self.mcu_endstop.home_start(*args, **kwargs)
+
+ def home_wait(self, *args, **kwargs):
+ """Forward home_wait() to the wrapped MCU endstop."""
+ return self.mcu_endstop.home_wait(*args, **kwargs)
+
+ def query_endstop(self, print_time):
+ """Forward query_endstop() to the wrapped MCU endstop."""
+ return self.mcu_endstop.query_endstop(print_time)
+
+
+class HomingCompat:
+ """Wraps homing and Z endstop API assumptions."""
+
+ def __init__(self, printer):
+ self.printer = printer
+ self.objects = PrinterObjectCompat(printer)
+
+ def get_z_endstop(self, query_endstops, section_name):
+ """Find and wrap the physical Z calibration endstop."""
+ z_endstop = None
+ for endstop, name in query_endstops.endstops:
+ if name == 'stepper_z' or name == 'z':
+ if not isinstance(endstop, MCU_endstop):
+ raise self.printer.config_error(
+ "A virtual endstop for z is not supported for %s"
+ % (section_name,))
+ z_endstop = EndstopWrapper(endstop)
+ if z_endstop is None:
+ raise self.printer.config_error("No z-endstop found for %s"
+ % (section_name,))
+ return z_endstop
+
+ def get_z_rail_settings(self, rail):
+ """Extract Z rail homing settings from a Klipper rail object."""
+ if not rail.get_steppers()[0].is_active_axis('z'):
+ return None
+ return {
+ 'position_endstop': rail.position_endstop,
+ 'homing_speed': rail.homing_speed,
+ 'second_homing_speed': rail.second_homing_speed,
+ 'homing_retract_dist': rail.homing_retract_dist,
+ 'position_min': rail.position_min,
+ }
+
+ def probing_move(self, mcu_endstop, pos, speed):
+ """Call Klipper's probing move through the homing object."""
+ homing = self.objects.lookup_homing()
+ return homing.probing_move(mcu_endstop, pos, speed)
+
+
+class ToolheadCompat:
+ """Wraps toolhead movement and status calls."""
+
+ def __init__(self, printer):
+ self.printer = printer
+ self.objects = PrinterObjectCompat(printer)
+
+ def _toolhead(self):
+ """Return the current toolhead object."""
+ return self.objects.lookup_toolhead()
+
+ def get_position(self):
+ """Return the current toolhead position."""
+ return self._toolhead().get_position()
+
+ def manual_move(self, coord, speed):
+ """Move the toolhead manually through Klipper."""
+ self._toolhead().manual_move(coord, speed)
+
+ def get_last_move_time(self):
+ """Return the print time for the last toolhead move."""
+ return self._toolhead().get_last_move_time()
+
+ def is_axis_homed(self, axis):
+ """Return whether Klipper currently reports an axis as homed."""
+ eventtime = self.printer.get_reactor().monotonic()
+ homed_axes = self._toolhead().get_status(eventtime).get(
+ 'homed_axes', '')
+ return axis in homed_axes
+
+
+class BedMeshCompat:
+ """Reads bed mesh zero-reference positions across Klipper versions."""
+
+ def get_zero_reference_position(self, mesh):
+ """Return the mesh zero reference position when configured."""
+ if mesh is None:
+ return None
+ bmc = getattr(mesh, 'bmc', None)
+ if bmc is None:
+ return None
+ if (hasattr(bmc, 'probe_mgr')
+ and bmc.probe_mgr.zero_ref_pos is not None):
+ return bmc.probe_mgr.zero_ref_pos
+ if hasattr(bmc, 'zero_ref_pos') and bmc.zero_ref_pos is not None:
+ # TODO: remove - deprecated since 2024-06
+ return bmc.zero_ref_pos
+ if (hasattr(bmc, 'relative_reference_index')
+ and bmc.relative_reference_index is not None):
+ # TODO: remove: trying to read the deprecated rri
+ rri = bmc.relative_reference_index
+ return bmc.points[rri]
+ return None
+
+
+class ProbeCompat:
+ """Adapts modern and legacy Klipper probe APIs."""
+
+ def __init__(self, helper, probe, gcmd=None):
+ self.helper = helper
+ self.probe = probe
+ self.gcmd = gcmd
+ self.gcode = helper.gcode
+ self.session = None
+
+ def get_config_defaults(self):
+ """Return probe defaults used by z_calibration config fallbacks."""
+ # TODO: remove: deprecated since 2024-06-10
+ if hasattr(self.probe, 'sample_count'):
+ return {
+ 'samples': self.probe.sample_count,
+ 'samples_tolerance': self.probe.samples_tolerance,
+ 'samples_tolerance_retries': self.probe.samples_retries,
+ 'lift_speed': self.probe.lift_speed,
+ 'samples_result': self.probe.samples_result,
+ 'safe_z_height': self.probe.z_offset * 2,
+ }
+ probe_params = self.probe.get_probe_params()
+ return {
+ 'samples': probe_params['samples'],
+ 'samples_tolerance': probe_params['samples_tolerance'],
+ 'samples_tolerance_retries': (
+ probe_params['samples_tolerance_retries']),
+ 'lift_speed': probe_params['lift_speed'],
+ 'samples_result': probe_params['samples_result'],
+ 'safe_z_height': self.probe.get_offsets()[2] * 2,
+ }
+
+ def get_offsets(self):
+ """Return configured probe offsets."""
+ return self.probe.get_offsets()
+
+ def start(self):
+ """Start the best supported probe session/profile."""
+ if hasattr(self.probe, 'start_probe_session'):
+ self.session = self.probe.start_probe_session(self.gcmd)
+ elif hasattr(self.probe, 'multi_probe_begin'):
+ # TODO: remove: deprecated since 2024-06-10
+ self.probe.multi_probe_begin()
+ else:
+ # TODO: remove: deprecated since 2024-06-10
+ self.probe.probe_session.start_probe_session(None)
+
+ def end(self):
+ """End the active probe session/profile."""
+ if self.session is not None:
+ self.session.end_probe_session()
+ self.session = None
+ elif hasattr(self.probe, 'multi_probe_end'):
+ # TODO: remove: deprecated since 2024-06-10
+ self.probe.multi_probe_end()
+ else:
+ # TODO: remove: deprecated since 2024-06-10
+ self.probe.probe_session.end_probe_session()
+
+ def query_endstop(self, print_time):
+ """Query the first supported probe endstop candidate."""
+ for probe_endstop in self._query_endstop_candidates():
+ query_endstop = getattr(probe_endstop, 'query_endstop', None)
+ if query_endstop is not None:
+ return query_endstop(print_time)
+ raise self.gcmd.error("%s: probe does not support endstop queries"
+ % (self.gcmd.get_command(),))
+
+ def can_probe(self):
+ """Return whether a modern session can run probe samples."""
+ return self.session is not None and hasattr(self.session, 'run_probe')
+
+ def get_legacy_probe_endstop(self):
+ """Return the MCU endstop used by the legacy fallback path."""
+ probe_endstop = getattr(self.probe, 'mcu_probe', None)
+ if probe_endstop is None:
+ return None
+ if hasattr(probe_endstop, 'get_steppers'):
+ return probe_endstop
+ return getattr(probe_endstop, 'mcu_endstop', None)
+
+ def run_probe(self, speed, samples=None):
+ """Run a probe sample through a modern probe session."""
+ if not self.can_probe():
+ return None
+ pgcmd = self._create_probe_gcmd(speed, samples)
+ self.session.run_probe(pgcmd)
+ results = self.session.pull_probed_results()
+ if not results:
+ raise self.gcmd.error("%s: probe did not return a result"
+ % (self.gcmd.get_command(),))
+ return results[-1]
+
+ def get_test_position(self, probe_result):
+ """Extract the raw trigger/test position from a probe result."""
+ if hasattr(probe_result, 'test_z'):
+ return [probe_result.test_x, probe_result.test_y,
+ probe_result.test_z]
+ if len(probe_result) >= 6:
+ return [probe_result[3], probe_result[4], probe_result[5]]
+ return probe_result[:3]
+
+ def _create_probe_gcmd(self, speed, samples):
+ """Create the synthetic PROBE command used by probe sessions."""
+ params = {}
+ if hasattr(self.gcmd, 'get_command_parameters'):
+ params.update(self.gcmd.get_command_parameters())
+ samples_result = self.helper.samples_result or 'average'
+ params.update({
+ 'PROBE_SPEED': str(speed),
+ 'LIFT_SPEED': str(self.helper.lift_speed),
+ 'SAMPLES': str(samples or self.helper.samples),
+ 'SAMPLE_RETRACT_DIST': str(self.helper.retract_dist),
+ 'SAMPLES_TOLERANCE': str(self.helper.tolerance),
+ 'SAMPLES_TOLERANCE_RETRIES': str(self.helper.retries),
+ 'SAMPLES_RESULT': samples_result,
+ })
+ command = self.gcmd.get_command()
+ return self.gcode.create_gcode_command(command, command, params)
+
+ def _query_endstop_candidates(self):
+ """Return probe objects that may expose query_endstop()."""
+ probe_endstop = getattr(self.probe, 'mcu_probe', None)
+ candidates = [self.probe, probe_endstop]
+ if probe_endstop is not None:
+ candidates.append(getattr(probe_endstop, 'mcu_endstop', None))
+ return [candidate for candidate in candidates if candidate is not None]
+
+
+class GCodeOffsetCompat:
+ """Applies new Z offsets through Klipper's G-Code move object."""
+
+ def __init__(self, gcode, gcode_move=None, offset_gcode=None):
+ self.gcode = gcode
+ self.gcode_move = gcode_move
+ self.offset_gcode = offset_gcode
+
+ def set_new_offset(self, offset):
+ """Reset the old Z offset and apply the newly calculated adjust."""
+ if self.offset_gcode is not None:
+ self._run_offset_gcode(offset)
+ return
+ gcmd_offset = self.gcode.create_gcode_command("SET_GCODE_OFFSET",
+ "SET_GCODE_OFFSET",
+ {'Z': 0.0})
+ self.gcode_move.cmd_SET_GCODE_OFFSET(gcmd_offset)
+ gcmd_offset = self.gcode.create_gcode_command("SET_GCODE_OFFSET",
+ "SET_GCODE_OFFSET",
+ {'Z_ADJUST': offset})
+ self.gcode_move.cmd_SET_GCODE_OFFSET(gcmd_offset)
+
+ def _run_offset_gcode(self, offset):
+ """Run a configured offset_gcode template with params.Z."""
+ run_gcode_template(self.offset_gcode, {'Z': offset})
+
+
+def run_gcode_template(template, params):
+ """Run a loaded G-Code template with command-style parameters."""
+ params = {name: str(value) for name, value in params.items()}
+ context = template.create_template_context()
+ context['params'] = params
+ context['rawparams'] = ' '.join(["%s=%s" % item
+ for item in params.items()])
+ template.run_gcode_from_command(context)
diff --git a/pictures/banner-dark.png b/pictures/banner-dark.png
new file mode 100644
index 0000000..665fe3f
Binary files /dev/null and b/pictures/banner-dark.png differ
diff --git a/pictures/banner-light.png b/pictures/banner-light.png
new file mode 100644
index 0000000..19f1421
Binary files /dev/null and b/pictures/banner-light.png differ
diff --git a/pictures/banner.png b/pictures/banner.png
deleted file mode 100644
index 1512c36..0000000
Binary files a/pictures/banner.png and /dev/null differ
diff --git a/pictures/social-preview.png b/pictures/social-preview.png
new file mode 100644
index 0000000..d8075a0
Binary files /dev/null and b/pictures/social-preview.png differ
diff --git a/scripts/check_all.py b/scripts/check_all.py
new file mode 100644
index 0000000..3305d09
--- /dev/null
+++ b/scripts/check_all.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+# Run the local validation suite used by contributors and CI.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import os
+import subprocess
+import sys
+
+
+PYTHON = sys.executable or 'python3'
+PYTHON_TARGETS = (
+ 'z_calibration.py',
+ 'klipper_compat.py',
+ 'scripts',
+ 'tests',
+)
+PYCACHE_ENV = {
+ 'PYTHONPYCACHEPREFIX': '/tmp/klipper_z_calibration-pycache',
+}
+TEST_ENV = {
+ 'PYTHONDONTWRITEBYTECODE': '1',
+ 'PYTHONPYCACHEPREFIX': PYCACHE_ENV['PYTHONPYCACHEPREFIX'],
+}
+COMMANDS = (
+ ((PYTHON, 'scripts/check_whitespace.py'), None),
+ (('bash', '-n', 'install.sh'), None),
+ ((PYTHON, '-m', 'compileall') + PYTHON_TARGETS, PYCACHE_ENV),
+ ((PYTHON, '-m', 'unittest', 'discover', '-s', 'tests', '-v'), TEST_ENV),
+ (('git', 'diff', '--check'), None),
+)
+
+
+def command_text(command, env_updates=None):
+ """Render a command line with any environment overrides."""
+ text = ' '.join(command)
+ if not env_updates:
+ return text
+ env_text = ' '.join(['%s=%s' % item
+ for item in sorted(env_updates.items())])
+ return 'env %s %s' % (env_text, text)
+
+
+def run_command(command, env_updates=None):
+ """Run one validation command and return its exit status."""
+ sys.stdout.write("+ %s\n" % (command_text(command, env_updates),))
+ sys.stdout.flush()
+ env = os.environ.copy()
+ if env_updates:
+ env.update(env_updates)
+ return subprocess.call(command, env=env)
+
+
+def run_all(commands=COMMANDS):
+ """Run validation commands until the first failure."""
+ for command, env_updates in commands:
+ ret = run_command(command, env_updates)
+ if ret:
+ return ret
+ return 0
+
+
+def main():
+ """CLI entrypoint for the aggregate validation runner."""
+ return run_all()
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/check_firmware_compat.py b/scripts/check_firmware_compat.py
new file mode 100644
index 0000000..133fe24
--- /dev/null
+++ b/scripts/check_firmware_compat.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+# Clone or update firmware checkouts and run compatibility contracts.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import argparse
+import importlib.util
+import os
+import pathlib
+import re
+import subprocess
+import sys
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
+DEFAULT_REPO_DIR = ROOT / '.compat_repos'
+KLIPPER_URL = 'https://github.com/Klipper3d/klipper.git'
+KALICO_URL = 'https://github.com/KalicoCrew/kalico.git'
+TAG_RE = re.compile(r'refs/tags/(v[0-9]+\.[0-9]+\.[0-9]+)$')
+COLOR_GREEN = '\033[32m'
+COLOR_RED = '\033[31m'
+COLOR_RESET = '\033[0m'
+
+
+def load_contract_checker():
+ """Load the source contract checker from the scripts directory."""
+ path = SCRIPT_DIR / 'check_klipper_contract.py'
+ spec = importlib.util.spec_from_file_location(path.stem, path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+check_klipper_contract = load_contract_checker()
+
+
+def run(command, cwd=None, capture=False):
+ """Run a subprocess while echoing the command."""
+ text = ' '.join([str(item) for item in command])
+ sys.stdout.write("+ %s\n" % (text,))
+ sys.stdout.flush()
+ kwargs = {
+ 'cwd': str(cwd) if cwd is not None else None,
+ 'text': True,
+ 'check': True,
+ }
+ if capture:
+ kwargs.update({'stdout': subprocess.PIPE})
+ return subprocess.run([str(item) for item in command], **kwargs)
+
+
+def use_color(stream):
+ """Return whether status output should use ANSI colors."""
+ return stream.isatty() and os.environ.get('NO_COLOR') is None
+
+
+def color_text(text, color, enabled):
+ """Wrap text in an ANSI color when enabled."""
+ if not enabled:
+ return text
+ return "%s%s%s" % (color, text, COLOR_RESET)
+
+
+def write_result_line(name, status, detail='', color=None):
+ """Write one unittest-style compatibility result line."""
+ enabled = use_color(sys.stdout)
+ if color is not None:
+ status = color_text(status, color, enabled)
+ line = " %-15s ... %s" % (name, status)
+ if detail:
+ line += " (%s)" % (detail,)
+ sys.stdout.write(line + "\n")
+
+
+def latest_klipper_tag():
+ """Return the newest stable Klipper release tag from the remote."""
+ result = run(
+ ('git', 'ls-remote', '--tags', '--refs', KLIPPER_URL, 'v*'),
+ capture=True)
+ tags = []
+ for line in result.stdout.splitlines():
+ match = TAG_RE.search(line)
+ if match is not None:
+ tags.append(match.group(1))
+ if not tags:
+ raise RuntimeError("Unable to find latest Klipper release tag")
+ return sorted(tags, key=version_key)[-1]
+
+
+def version_key(tag):
+ """Return a sortable tuple for a vX.Y.Z tag."""
+ return tuple([int(part) for part in tag[1:].split('.')])
+
+
+def clone_or_update(path, url, ref, update=True):
+ """Create or refresh one local firmware checkout."""
+ path = pathlib.Path(path)
+ if not path.exists():
+ if ref is None:
+ raise RuntimeError(
+ "missing %s; run without --no-update first" % (path,))
+ run(('git', 'clone', '--depth', '1', '--branch', ref, url, path))
+ return
+ if not update:
+ return
+ run(('git', 'fetch', '--depth', '1', 'origin', ref), cwd=path)
+ run(('git', 'checkout', '--detach', 'FETCH_HEAD'), cwd=path)
+
+
+def check_contract(name, path):
+ """Run source contract checks for one firmware checkout."""
+ errors = check_klipper_contract.check_klipper_contract(path)
+ if errors:
+ write_result_line(name, 'FAIL', color=COLOR_RED)
+ for error in errors:
+ sys.stdout.write(" - %s\n" % (error,))
+ return 1
+ profiles = check_klipper_contract.get_contract_profiles(path)
+ write_result_line(name, 'ok', ', '.join(profiles), COLOR_GREEN)
+ return 0
+
+
+def get_targets(repo_dir, update=True):
+ """Return firmware targets checked by the compatibility suite."""
+ klipper_ref = latest_klipper_tag() if update else None
+ return [
+ ('klipper-release', KLIPPER_URL, klipper_ref,
+ repo_dir / 'klipper-release'),
+ ('klipper-master', KLIPPER_URL, 'master' if update else None,
+ repo_dir / 'klipper-master'),
+ ('kalico-main', KALICO_URL, 'main' if update else None,
+ repo_dir / 'kalico-main'),
+ ]
+
+
+def run_checks(repo_dir, update=True):
+ """Prepare firmware checkouts and run all contract checks."""
+ repo_dir = pathlib.Path(repo_dir)
+ repo_dir.mkdir(parents=True, exist_ok=True)
+ targets = get_targets(repo_dir, update=update)
+ for _name, url, ref, path in targets:
+ if ref is None and not path.exists():
+ raise RuntimeError(
+ "missing %s; run without --no-update first" % (path,))
+ clone_or_update(path, url, ref, update=update)
+ sys.stdout.write("\nFirmware compatibility checks: %s\n" % (repo_dir,))
+ result = 0
+ for name, _url, _ref, path in targets:
+ result |= check_contract(name, path)
+ if result:
+ summary = color_text('FAILED', COLOR_RED, use_color(sys.stdout))
+ else:
+ summary = color_text('OK', COLOR_GREEN, use_color(sys.stdout))
+ sys.stdout.write("\nFirmware compatibility result: %s\n" % (summary,))
+ return result
+
+
+def parse_args(argv):
+ """Parse firmware compatibility checker arguments."""
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--repo-dir', default=str(DEFAULT_REPO_DIR),
+ help='directory for local firmware checkouts')
+ parser.add_argument('--no-update', action='store_true',
+ help='reuse existing checkouts without fetching')
+ return parser.parse_args(argv)
+
+
+def main(argv=None):
+ """CLI entrypoint for firmware compatibility checks."""
+ args = parse_args(argv or sys.argv[1:])
+ try:
+ return run_checks(args.repo_dir, update=not args.no_update)
+ except (RuntimeError, subprocess.CalledProcessError) as err:
+ sys.stderr.write("%s\n" % (err,))
+ return 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/check_klipper_contract.py b/scripts/check_klipper_contract.py
new file mode 100644
index 0000000..e8601c7
--- /dev/null
+++ b/scripts/check_klipper_contract.py
@@ -0,0 +1,311 @@
+#!/usr/bin/env python3
+# Validate Klipper source contracts used by z_calibration.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import argparse
+import ast
+import pathlib
+import sys
+
+
+class ContractError(Exception):
+ """Raised when an expected upstream source file cannot be inspected."""
+
+ pass
+
+
+PROFILE_VALIDATORS = []
+
+
+def probe_profile(name):
+ """Register a supported probe compatibility profile validator."""
+ def register(func):
+ """Store the decorated profile validator."""
+ PROFILE_VALIDATORS.append((name, func))
+ return func
+ return register
+
+
+def read_source(root, relpath):
+ """Read and parse a Klipper source file."""
+ path = pathlib.Path(root) / relpath
+ if not path.is_file():
+ raise ContractError("missing %s" % (relpath,))
+ source = path.read_text(encoding='utf-8')
+ return source, ast.parse(source, filename=str(path))
+
+
+def read_existing_sources(root, relpaths):
+ """Read every existing source from a fallback path list."""
+ sources = []
+ for relpath in relpaths:
+ try:
+ sources.append(read_source(root, relpath))
+ except ContractError:
+ pass
+ if not sources:
+ raise ContractError("missing one of %s" % (', '.join(relpaths),))
+ return sources
+
+
+def any_has_probe_result(sources):
+ """Return whether any parsed source defines ProbeResult."""
+ for _source, tree in sources:
+ if has_class(tree, 'ProbeResult') or has_assignment(tree,
+ 'ProbeResult'):
+ return True
+ return False
+
+
+def has_class(tree, class_name):
+ """Return whether an AST contains a class definition."""
+ return any(isinstance(node, ast.ClassDef) and node.name == class_name
+ for node in ast.walk(tree))
+
+
+def has_function(tree, function_name):
+ """Return whether an AST contains a function definition."""
+ return any(isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
+ and node.name == function_name for node in ast.walk(tree))
+
+
+def class_has_function(tree, class_name, function_name):
+ """Return whether a class defines a specific method."""
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.ClassDef) or node.name != class_name:
+ continue
+ return any(isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
+ and child.name == function_name for child in node.body)
+ return False
+
+
+def has_assignment(tree, target_name):
+ """Return whether an AST assigns to a top-level-style name."""
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Assign):
+ targets = node.targets
+ elif isinstance(node, ast.AnnAssign):
+ targets = [node.target]
+ else:
+ continue
+ for target in targets:
+ if isinstance(target, ast.Name) and target.id == target_name:
+ return True
+ return False
+
+
+def require(condition, message, errors):
+ """Append a contract error message when a condition is false."""
+ if not condition:
+ errors.append(message)
+
+
+def format_errors(errors):
+ """Prefix raw contract errors for CLI output."""
+ return ["Klipper contract failed: %s" % (error,) for error in errors]
+
+
+def validate_probe_session(root, errors):
+ """Validate source markers for modern probe sessions."""
+ # This source-level check can prove that a session API exists, but it
+ # cannot prove the runtime object returned by start_probe_session().
+ # Behavior of the returned session stays covered by wrapper/unit tests.
+ _source, tree = read_source(root, 'klippy/extras/probe.py')
+ require(class_has_function(tree, 'PrinterProbe', 'start_probe_session'),
+ 'PrinterProbe.start_probe_session not found', errors)
+ require(has_function(tree, 'run_probe'),
+ 'probe session run_probe not found', errors)
+ require(has_function(tree, 'pull_probed_results'),
+ 'probe session pull_probed_results not found', errors)
+ require(has_function(tree, 'end_probe_session'),
+ 'probe session end_probe_session not found', errors)
+
+
+def validate_probe_result(root, errors):
+ """Validate source markers for raw test-position probe results."""
+ # ProbeResult may move between probe/manual_probe sources. This check
+ # guards the coordinate contract, but runtime still accepts tuple/list
+ # results for older profiles.
+ sources = read_existing_sources(root, [
+ 'klippy/extras/manual_probe.py',
+ 'klippy/extras/probe.py',
+ ])
+ source = '\n'.join([item[0] for item in sources])
+ require(any_has_probe_result(sources), 'ProbeResult not found', errors)
+ for attr in ['test_x', 'test_y', 'test_z', 'bed_z']:
+ require(attr in source, 'ProbeResult.%s not found' % (attr,), errors)
+
+
+def validate_probe_endstop_wrapper(root, errors):
+ """Validate source markers for legacy probe endstop wrappers."""
+ # This covers the legacy downstream contract where the plugin passes a
+ # probe endstop object into homing.probing_move(). A wrapper exposing only
+ # query_endstop() is not enough; probing_move needs the MCU endstop surface.
+ #
+ # Weak point: source markers cannot prove which concrete object is stored
+ # in probe.mcu_probe at runtime, or whether the usable MCU endstop is nested
+ # as probe.mcu_probe.mcu_endstop. The runtime validator covers that shape.
+ source, tree = read_source(root, 'klippy/extras/probe.py')
+ require(has_class(tree, 'ProbeEndstopWrapper'),
+ 'ProbeEndstopWrapper not found', errors)
+ for marker in ['mcu_probe', 'get_steppers', 'home_start',
+ 'home_wait', 'query_endstop']:
+ require(marker in source,
+ 'ProbeEndstopWrapper.%s marker not found' % (marker,),
+ errors)
+
+
+@probe_profile('modern_probe_result_session')
+def validate_modern_probe_result_session(root, errors):
+ """Validate the modern ProbeResult session profile."""
+ validate_probe_session(root, errors)
+ validate_probe_result(root, errors)
+
+
+@probe_profile('probe_session_xyz_list')
+def validate_probe_session_xyz_list(root, errors):
+ """Validate a session profile that returns XYZ list results."""
+ validate_probe_session(root, errors)
+
+
+@probe_profile('legacy_mcu_endstop_probe')
+def validate_legacy_mcu_endstop_probe(root, errors):
+ """Validate the legacy MCU endstop probing profile."""
+ # Keep this profile narrow: it validates the old fallback path only when
+ # the modern probe-session profiles are unavailable. A Klipper version can
+ # pass a modern profile while still changing legacy wrapper internals; that
+ # is acceptable as long as z_calibration uses the modern runtime path.
+ source, tree = read_source(root, 'klippy/extras/probe.py')
+ require(has_class(tree, 'PrinterProbe'), 'PrinterProbe not found', errors)
+ require(class_has_function(tree, 'PrinterProbe', 'multi_probe_begin'),
+ 'PrinterProbe.multi_probe_begin not found', errors)
+ require(class_has_function(tree, 'PrinterProbe', 'multi_probe_end'),
+ 'PrinterProbe.multi_probe_end not found', errors)
+ require(class_has_function(tree, 'PrinterProbe', 'get_offsets'),
+ 'PrinterProbe.get_offsets not found', errors)
+ has_legacy_defaults = (
+ 'sample_count' in source and 'samples_tolerance' in source
+ and 'samples_retries' in source and 'lift_speed' in source
+ and 'samples_result' in source and 'z_offset' in source)
+ require(has_legacy_defaults or has_function(tree, 'get_probe_params'),
+ 'probe defaults are not exposed', errors)
+ if not class_has_function(tree, 'PrinterProbe', 'run_probe'):
+ validate_probe_endstop_wrapper(root, errors)
+ require('mcu_probe' in source, 'PrinterProbe.mcu_probe not found', errors)
+ require('query_endstop' in source,
+ 'probe endstop query path not found', errors)
+
+
+def validate_homing(root, errors):
+ """Validate source markers for homing.probing_move."""
+ _source, tree = read_source(root, 'klippy/extras/homing.py')
+ require(has_function(tree, 'probing_move'),
+ 'homing.probing_move not found', errors)
+
+
+def validate_bed_mesh(root, errors):
+ """Validate source markers for bed mesh zero-reference lookup."""
+ source, _tree = read_source(root, 'klippy/extras/bed_mesh.py')
+ markers = [
+ 'zero_reference_position',
+ 'zero_ref_pos',
+ 'probe_mgr',
+ 'relative_reference_index',
+ ]
+ require(any(marker in source for marker in markers),
+ 'bed_mesh zero reference path not found', errors)
+
+
+def validate_mcu(root, errors):
+ """Validate source markers for MCU_endstop."""
+ _source, tree = read_source(root, 'klippy/mcu.py')
+ require(has_class(tree, 'MCU_endstop'), 'MCU_endstop not found', errors)
+
+
+def validate_gcode_macro(root, errors):
+ """Validate source markers for configured G-Code template hooks."""
+ source, tree = read_source(root, 'klippy/extras/gcode_macro.py')
+ require(class_has_function(tree, 'PrinterGCodeMacro', 'load_template'),
+ 'PrinterGCodeMacro.load_template not found', errors)
+ require(has_function(tree, 'run_gcode_from_command'),
+ 'template run_gcode_from_command not found', errors)
+ require('create_template_context' in source,
+ 'template create_template_context not found', errors)
+
+
+def validate_baseline(root):
+ """Validate non-profile contracts required by all supported profiles."""
+ errors = []
+ try:
+ validate_homing(root, errors)
+ validate_bed_mesh(root, errors)
+ validate_mcu(root, errors)
+ validate_gcode_macro(root, errors)
+ except ContractError as err:
+ errors.append(str(err))
+ return errors
+
+
+def probe_profile_errors(root):
+ """Return matching probe profiles and per-profile failures."""
+ profile_errors = []
+ matches = []
+ for name, validator in PROFILE_VALIDATORS:
+ errors = []
+ try:
+ validator(root, errors)
+ except ContractError as err:
+ errors.append(str(err))
+ if not errors:
+ matches.append(name)
+ else:
+ profile_errors.append((name, errors))
+ return matches, profile_errors
+
+
+def get_contract_profiles(root):
+ """Return supported profile names for a Klipper checkout."""
+ baseline_errors = validate_baseline(root)
+ if baseline_errors:
+ return []
+ matches, _profile_errors = probe_profile_errors(root)
+ return matches
+
+
+def check_klipper_contract(root):
+ """Return formatted contract errors for a Klipper checkout."""
+ baseline_errors = validate_baseline(root)
+ if baseline_errors:
+ return format_errors(baseline_errors)
+ matches, profile_errors = probe_profile_errors(root)
+ if matches:
+ return []
+ errors = ['no supported probe compatibility profile found']
+ for name, missing in profile_errors:
+ errors.append("%s missing: %s" % (name, '; '.join(missing)))
+ return format_errors(errors)
+
+
+def parse_args(argv):
+ """Parse source contract checker arguments."""
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--klipper-path', required=True)
+ return parser.parse_args(argv)
+
+
+def main(argv=None):
+ """CLI entrypoint for source contract validation."""
+ args = parse_args(argv or sys.argv[1:])
+ errors = check_klipper_contract(args.klipper_path)
+ if errors:
+ sys.stderr.write('\n'.join(errors) + '\n')
+ return 1
+ profiles = ', '.join(get_contract_profiles(args.klipper_path))
+ sys.stdout.write("Klipper contract checks passed: %s\n" % (profiles,))
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/check_release.py b/scripts/check_release.py
new file mode 100644
index 0000000..e4ab348
--- /dev/null
+++ b/scripts/check_release.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+# Validate release tags and expose metadata for GitHub Actions.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import argparse
+import pathlib
+import re
+import sys
+
+
+STABLE_RE = re.compile(r'^v(?P\d+\.\d+\.\d+)$')
+BETA_RE = re.compile(r'^v(?P\d+\.\d+\.\d+-beta\.\d+)$')
+
+
+class ReleaseError(Exception):
+ """Raised when release tag metadata is invalid."""
+
+ pass
+
+
+def classify_tag(tag):
+ """Classify a tag as stable or beta release metadata."""
+ stable = STABLE_RE.match(tag)
+ if stable is not None:
+ return {
+ 'tag': tag,
+ 'version': stable.group('version'),
+ 'channel': 'stable',
+ 'prerelease': 'false',
+ 'title': tag,
+ }
+ beta = BETA_RE.match(tag)
+ if beta is not None:
+ return {
+ 'tag': tag,
+ 'version': beta.group('version'),
+ 'channel': 'beta',
+ 'prerelease': 'true',
+ 'title': tag,
+ }
+ raise ReleaseError(
+ "invalid release tag %r; expected vX.Y.Z or vX.Y.Z-beta.N" % (tag,))
+
+
+def validate_channel(metadata, expected_channel):
+ """Ensure the tag channel matches an optional expected channel."""
+ if expected_channel is None:
+ return
+ if metadata['channel'] != expected_channel:
+ raise ReleaseError(
+ "tag %s is %s, not %s"
+ % (metadata['tag'], metadata['channel'], expected_channel))
+
+
+def write_outputs(path, metadata):
+ """Append GitHub Actions output values for release metadata."""
+ output_path = pathlib.Path(path)
+ with output_path.open('a', encoding='utf-8') as output_file:
+ for key in ['tag', 'version', 'channel', 'prerelease', 'title']:
+ output_file.write("%s=%s\n" % (key, metadata[key]))
+
+
+def parse_args(argv):
+ """Parse release validation arguments."""
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--tag', required=True)
+ parser.add_argument('--channel', choices=['stable', 'beta'])
+ parser.add_argument('--github-output')
+ return parser.parse_args(argv)
+
+
+def main(argv=None):
+ """CLI entrypoint for release metadata validation."""
+ args = parse_args(argv or sys.argv[1:])
+ try:
+ metadata = classify_tag(args.tag)
+ validate_channel(metadata, args.channel)
+ except ReleaseError as err:
+ sys.stderr.write(str(err) + "\n")
+ return 1
+ if args.github_output:
+ write_outputs(args.github_output, metadata)
+ sys.stdout.write(
+ "%s %s %s\n"
+ % (metadata['tag'], metadata['channel'], metadata['prerelease']))
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/check_whitespace.py b/scripts/check_whitespace.py
new file mode 100644
index 0000000..4846daf
--- /dev/null
+++ b/scripts/check_whitespace.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+# Check Klipper-style whitespace and formatting rules.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import pathlib
+import sys
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+EXCLUDED_DIRS = {
+ '.compat_repos',
+ '.git',
+ '.mypy_cache',
+ '.pytest_cache',
+ '.ruff_cache',
+ '__pycache__',
+}
+SKIP_SUFFIXES = {
+ '.gif',
+ '.ico',
+ '.jpg',
+ '.jpeg',
+ '.pdf',
+ '.png',
+}
+
+
+def iter_files():
+ """Yield repository files that should be whitespace checked."""
+ for path in sorted(ROOT.rglob('*')):
+ if not path.is_file():
+ continue
+ relpath = path.relative_to(ROOT)
+ if any(part in EXCLUDED_DIRS for part in relpath.parts):
+ continue
+ if path.suffix.lower() in SKIP_SUFFIXES:
+ continue
+ yield path
+
+
+def is_makefile(path):
+ """Return whether tabs are allowed in this file."""
+ return path.name == 'Makefile' or path.suffix == '.mk'
+
+
+def report(errors, path, lineno, msg):
+ """Append a formatted whitespace error."""
+ relpath = path.relative_to(ROOT)
+ if lineno is None:
+ errors.append("%s: %s" % (relpath, msg))
+ else:
+ errors.append("%s:%d: %s" % (relpath, lineno, msg))
+
+
+def check_file(path, errors):
+ """Check one file for encoding and whitespace violations."""
+ data = path.read_bytes()
+ try:
+ text = data.decode('utf-8')
+ except UnicodeDecodeError:
+ report(errors, path, None, "not utf-8 encoded")
+ return
+ if data and not data.endswith(b'\n'):
+ report(errors, path, None, "missing newline at end of file")
+ if text.endswith('\n\n'):
+ report(errors, path, None, "extra blank line at end of file")
+ for lineno, line in enumerate(text.splitlines(), start=1):
+ if line.endswith(' ') or line.endswith('\t'):
+ report(errors, path, lineno, "trailing whitespace")
+ if '\t' in line and not is_makefile(path):
+ report(errors, path, lineno, "tab character")
+ if path.suffix == '.py' and len(line) > 80:
+ report(errors, path, lineno, "line longer than 80 characters")
+ for column, char in enumerate(line, start=1):
+ if ord(char) < 32 and char != '\t':
+ msg = "invalid control character at column %d" % (column,)
+ report(errors, path, lineno, msg)
+
+
+def main():
+ """CLI entrypoint for whitespace validation."""
+ errors = []
+ for path in iter_files():
+ check_file(path, errors)
+ if errors:
+ sys.stderr.write('\n'.join(errors) + '\n')
+ return 1
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/update_moonraker.py b/scripts/update_moonraker.py
new file mode 100644
index 0000000..fadfa59
--- /dev/null
+++ b/scripts/update_moonraker.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+# Update Moonraker config for the z_calibration update manager section.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import os
+import pathlib
+import re
+import sys
+import tempfile
+
+
+SECTION_RE = re.compile(r'^\[update_manager(?:\s+[^\]]*)?\s+z_calibration\]$')
+ANY_SECTION_RE = re.compile(r'^\[[^\]]+\]$')
+
+
+def _find_section(lines):
+ """Return the start/end indexes for the z_calibration updater section."""
+ start = None
+ for index, line in enumerate(lines):
+ if SECTION_RE.match(line.strip()):
+ start = index
+ break
+ if start is None:
+ return None, None
+ end = len(lines)
+ for index in range(start + 1, len(lines)):
+ if ANY_SECTION_RE.match(lines[index].strip()):
+ end = index
+ break
+ return start, end
+
+
+def _new_section(repo_path):
+ """Build a default stable Moonraker update_manager section."""
+ return [
+ "",
+ "[update_manager z_calibration]",
+ "type: git_repo",
+ "channel: stable",
+ "path: %s" % (repo_path,),
+ "origin: https://github.com/protoloft/klipper_z_calibration.git",
+ "managed_services: klipper",
+ "",
+ ]
+
+
+def update_config_text(text, repo_path):
+ """Add or migrate the update_manager section in Moonraker config text."""
+ lines = text.splitlines()
+ start, end = _find_section(lines)
+ if start is None:
+ new_lines = lines + _new_section(repo_path)
+ return "\n".join(new_lines).rstrip() + "\n", True
+ section = lines[start:end]
+ for line in section[1:]:
+ stripped = line.strip()
+ if not stripped or stripped.startswith('#'):
+ continue
+ key = stripped.split(':', 1)[0].strip().lower()
+ if key == 'channel':
+ return text, False
+ insert_at = start + 1
+ for index in range(start + 1, end):
+ stripped = lines[index].strip()
+ key = stripped.split(':', 1)[0].strip().lower()
+ if key == 'type':
+ insert_at = index + 1
+ break
+ new_lines = lines[:insert_at] + ["channel: stable"] + lines[insert_at:]
+ return "\n".join(new_lines).rstrip() + "\n", True
+
+
+def update_config_file(path, repo_path):
+ """Update a Moonraker config file and report whether it changed."""
+ config_path = pathlib.Path(path)
+ original = config_path.read_text(encoding='utf-8')
+ updated, changed = update_config_text(original, repo_path)
+ if changed:
+ write_config_atomically(config_path, original, updated)
+ return changed
+
+
+def write_config_atomically(config_path, original, updated):
+ """Back up the current config, then atomically replace it."""
+ backup_path = config_path.with_name(config_path.name + '.bak')
+ backup_path.write_text(original, encoding='utf-8')
+ mode = config_path.stat().st_mode
+ tmp_path = None
+ try:
+ fd, tmp_name = tempfile.mkstemp(
+ prefix=config_path.name + '.', suffix='.tmp',
+ dir=str(config_path.parent))
+ tmp_path = pathlib.Path(tmp_name)
+ with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
+ tmp_file.write(updated)
+ tmp_file.flush()
+ os.fsync(tmp_file.fileno())
+ os.chmod(tmp_path, mode)
+ os.replace(tmp_path, config_path)
+ finally:
+ if tmp_path is not None and tmp_path.exists():
+ tmp_path.unlink()
+
+
+def main():
+ """CLI entrypoint for Moonraker config migration."""
+ if len(sys.argv) != 3:
+ sys.stderr.write("Usage: update_moonraker.py \n")
+ return 2
+ changed = update_config_file(sys.argv[1], sys.argv[2])
+ if changed:
+ sys.stdout.write("changed\n")
+ else:
+ sys.stdout.write("unchanged\n")
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tests/fakes.py b/tests/fakes.py
new file mode 100644
index 0000000..f8d0b45
--- /dev/null
+++ b/tests/fakes.py
@@ -0,0 +1,465 @@
+# Shared fake Klipper/Kalico objects for unit tests.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+from collections import namedtuple
+
+
+class FakeError(Exception):
+ """Exception type returned by fake Klipper error factories."""
+
+ pass
+
+
+class FakeMCUEndstop:
+ """Minimal MCU endstop surface used by probing_move tests."""
+
+ def get_mcu(self):
+ return None
+
+ def add_stepper(self, stepper):
+ pass
+
+ def get_steppers(self):
+ return []
+
+ def home_start(self, *args, **kwargs):
+ pass
+
+ def home_wait(self, *args, **kwargs):
+ pass
+
+ def query_endstop(self, print_time):
+ return False
+
+
+ProbeResult = namedtuple(
+ 'probe_result',
+ ['bed_x', 'bed_y', 'bed_z', 'test_x', 'test_y', 'test_z'])
+
+
+class FakeGcmd:
+ """Small G-Code command object with parameter and response capture."""
+
+ def __init__(self, command='CALIBRATE_Z', params=None):
+ self.command = command
+ self.params = dict(params or {})
+ self.responses = []
+
+ def get_command(self):
+ return self.command
+
+ def get_command_parameters(self):
+ return dict(self.params)
+
+ def get(self, name, default=None):
+ return self.params.get(name, default)
+
+ def get_float(self, name, default=None, above=None, minval=None):
+ value = self.get(name, default)
+ if value is None:
+ return None
+ value = float(value)
+ if above is not None and value <= above:
+ raise self.error("invalid float")
+ if minval is not None and value < minval:
+ raise self.error("invalid float")
+ return value
+
+ def get_int(self, name, default=None, minval=None):
+ value = self.get(name, default)
+ if value is None:
+ return None
+ value = int(value)
+ if minval is not None and value < minval:
+ raise self.error("invalid int")
+ return value
+
+ def respond_info(self, message):
+ self.responses.append(message)
+
+ def error(self, message):
+ return FakeError(message)
+
+
+class FakeGCode:
+ """Captures registered commands and synthetic G-Code commands."""
+
+ def __init__(self):
+ self.commands = {}
+ self.created_commands = []
+ self.responses = []
+
+ def register_command(self, name, func, desc=None):
+ self.commands[name] = (func, desc)
+
+ def create_gcode_command(self, command, commandline, params):
+ gcmd = FakeGcmd(command, params)
+ self.created_commands.append(gcmd)
+ return gcmd
+
+ def respond_info(self, message):
+ self.responses.append(message)
+
+
+class FakeTemplate:
+ """Counts macro template executions."""
+
+ def __init__(self, name=None, executions=None):
+ self.name = name
+ self.calls = 0
+ self.contexts = []
+ self.exception = None
+ self.executions = executions
+
+ def create_template_context(self):
+ return {'printer': 'fake'}
+
+ def run_gcode_from_command(self, context=None):
+ self.calls += 1
+ self.contexts.append(context)
+ if self.executions is not None:
+ self.executions.append(self.name)
+ if self.exception is not None:
+ raise self.exception
+
+
+class FakeGCodeMacro:
+ """Creates fake templates for configured macro hooks."""
+
+ def __init__(self):
+ self.templates = {}
+ self.executions = []
+
+ def load_template(self, config, name, default=None):
+ template = FakeTemplate(name, self.executions)
+ self.templates[name] = template
+ return template
+
+
+class FakeConfig:
+ """Provides the subset of Klipper config parsing used by the plugin."""
+
+ def __init__(self, printer, values=None):
+ self.printer = printer
+ self.values = dict(values or {})
+
+ def get_printer(self):
+ return self.printer
+
+ def get_name(self):
+ return 'z_calibration'
+
+ def get(self, name, default=None):
+ return self.values.get(name, default)
+
+ def getfloat(self, name, default=None, above=None, minval=None):
+ value = self.get(name, default)
+ if value is None:
+ return None
+ value = float(value)
+ if above is not None and value <= above:
+ raise self.error("invalid float")
+ if minval is not None and value < minval:
+ raise self.error("invalid float")
+ return value
+
+ def getint(self, name, default=None, minval=None):
+ value = self.get(name, default)
+ if value is None:
+ return None
+ value = int(value)
+ if minval is not None and value < minval:
+ raise self.error("invalid int")
+ return value
+
+ def getboolean(self, name, default=False):
+ value = self.get(name, default)
+ if isinstance(value, str):
+ return value.lower() in ('true', '1', 'yes', 'on')
+ return bool(value)
+
+ def getchoice(self, name, choices, default=None):
+ value = self.get(name, default)
+ if isinstance(choices, dict):
+ if value not in choices:
+ raise self.error("invalid choice")
+ return choices[value]
+ if value not in choices:
+ raise self.error("invalid choice")
+ return value
+
+ def error(self, message):
+ return FakeError(message)
+
+
+class FakeReactor:
+ """Provides deterministic reactor time for status checks."""
+
+ def monotonic(self):
+ return 123.0
+
+
+class FakeToolhead:
+ """Tracks position, homing state, and requested manual moves."""
+
+ def __init__(self):
+ self.position = [0.0, 0.0, 10.0, 0.0]
+ self.homed_axes = 'xyz'
+ self.moves = []
+
+ def get_position(self):
+ return list(self.position)
+
+ def manual_move(self, coord, speed):
+ for idx, value in enumerate(coord):
+ if value is not None:
+ self.position[idx] = value
+ self.moves.append((list(coord), speed))
+
+ def get_last_move_time(self):
+ return 1.0
+
+ def get_status(self, eventtime):
+ return {'homed_axes': self.homed_axes}
+
+
+class FakeHoming:
+ """Returns queued probing results and records probing_move calls."""
+
+ def __init__(self, toolhead):
+ self.toolhead = toolhead
+ self.results = []
+ self.calls = []
+
+ def probing_move(self, endstop, pos, speed):
+ self.calls.append((endstop, list(pos), speed))
+ result = self.results.pop(0)
+ self.toolhead.position[:3] = result[:3]
+ return list(result)
+
+
+class FakeGCodeMove:
+ """Captures SET_GCODE_OFFSET command parameters."""
+
+ def __init__(self):
+ self.offset_commands = []
+
+ def cmd_SET_GCODE_OFFSET(self, gcmd):
+ self.offset_commands.append(gcmd.params)
+
+
+class FakeQueryEndstops:
+ """Exposes a default physical Z endstop entry."""
+
+ def __init__(self):
+ self.endstops = [(FakeMCUEndstop(), 'stepper_z')]
+
+
+class FakeProbeEndstop:
+ """Probe endstop that returns a configurable trigger state."""
+
+ def __init__(self, triggered=False):
+ self.triggered = triggered
+
+ def query_endstop(self, print_time):
+ return self.triggered
+
+
+class FakeProbeSession:
+ """Probe session with queued results and command capture."""
+
+ def __init__(self, results):
+ self.results = list(results)
+ self.pending = []
+ self.run_gcmds = []
+ self.ended = False
+
+ def run_probe(self, gcmd):
+ self.run_gcmds.append(gcmd)
+ self.pending.append(self.results.pop(0))
+
+ def pull_probed_results(self):
+ results = self.pending
+ self.pending = []
+ return results
+
+ def start_probe_session(self, gcmd):
+ pass
+
+ def end_probe_session(self):
+ self.ended = True
+
+
+class FakeEmptyProbeSession:
+ """Probe session that simulates a missing probe result."""
+
+ def run_probe(self, gcmd):
+ pass
+
+ def pull_probed_results(self):
+ return []
+
+ def end_probe_session(self):
+ pass
+
+
+class FakeProbe:
+ """Modern probe exposing start_probe_session and get_probe_params."""
+
+ def __init__(self, session=None, offsets=(1.0, 2.0, 1.5)):
+ self.mcu_probe = FakeProbeEndstop(False)
+ self.session = session or FakeProbeSession([])
+ self.offsets = offsets
+
+ def get_probe_params(self, gcmd=None):
+ return {
+ 'samples': 1,
+ 'samples_tolerance': 0.1,
+ 'samples_tolerance_retries': 0,
+ 'lift_speed': 5.0,
+ 'samples_result': 'average',
+ }
+
+ def get_offsets(self, gcmd=None):
+ return self.offsets
+
+ def start_probe_session(self, gcmd):
+ return self.session
+
+
+class FakeLegacyProbe:
+ """Legacy probe exposing multi_probe_begin/end fallback hooks."""
+
+ def __init__(self):
+ self.mcu_probe = FakeProbeEndstop(False)
+ self.offsets = (1.0, 2.0, 1.5)
+ self.begin_calls = 0
+ self.end_calls = 0
+
+ def get_probe_params(self):
+ return {
+ 'samples': 1,
+ 'samples_tolerance': 0.1,
+ 'samples_tolerance_retries': 0,
+ 'lift_speed': 5.0,
+ 'samples_result': 'average',
+ }
+
+ def get_offsets(self):
+ return self.offsets
+
+ def query_endstop(self, print_time):
+ return False
+
+ def multi_probe_begin(self):
+ self.begin_calls += 1
+
+ def multi_probe_end(self):
+ self.end_calls += 1
+
+
+class FakeOldProbe:
+ """Old probe exposing deprecated default attributes."""
+
+ sample_count = 2
+ samples_tolerance = 0.05
+ samples_retries = 3
+ lift_speed = 7.0
+ samples_result = 'median'
+ z_offset = 4.0
+
+ def __init__(self):
+ self.mcu_probe = FakeProbeEndstop(False)
+
+
+class FakeProbeWithProbeSession:
+ """Old probe exposing a nested probe_session object."""
+
+ def __init__(self):
+ self.probe_session = FakeProbeSession([])
+
+
+class FakePrinter:
+ """Printer object registry and error factory used by unit tests."""
+
+ missing = object()
+
+ def __init__(self, probe=None):
+ self.reactor = FakeReactor()
+ self.gcode = FakeGCode()
+ self.toolhead = FakeToolhead()
+ self.homing = FakeHoming(self.toolhead)
+ self.gcode_move = FakeGCodeMove()
+ self.gcode_macro = FakeGCodeMacro()
+ self.query_endstops = FakeQueryEndstops()
+ self.objects = {
+ 'gcode': self.gcode,
+ 'toolhead': self.toolhead,
+ 'homing': self.homing,
+ 'gcode_move': self.gcode_move,
+ 'gcode_macro': self.gcode_macro,
+ 'query_endstops': self.query_endstops,
+ 'probe': probe or FakeProbe(),
+ }
+ self.handlers = {}
+
+ def load_object(self, config, name):
+ return self.lookup_object(name)
+
+ def lookup_object(self, name, default=missing):
+ if name in self.objects:
+ return self.objects[name]
+ if default is not self.missing:
+ return default
+ raise KeyError(name)
+
+ def register_event_handler(self, name, handler):
+ self.handlers[name] = handler
+
+ def config_error(self, message):
+ return FakeError(message)
+
+ def command_error(self, message):
+ return FakeError(message)
+
+ def get_reactor(self):
+ return self.reactor
+
+ def send_event(self, name, *args):
+ pass
+
+
+class FakeStepper:
+ """Stepper that reports itself as active for Z."""
+
+ def is_active_axis(self, axis):
+ return axis == 'z'
+
+
+class FakeInactiveStepper:
+ """Stepper that does not report any active axis."""
+
+ def is_active_axis(self, axis):
+ return False
+
+
+class FakeRail:
+ """Z rail exposing homing settings consumed by HomingCompat."""
+
+ position_endstop = 0.0
+ homing_speed = 6.0
+ second_homing_speed = 2.0
+ homing_retract_dist = 1.0
+ position_min = -2.0
+
+ def get_steppers(self):
+ return [FakeStepper()]
+
+
+class FakeInactiveRail(FakeRail):
+ """Rail whose stepper is not active on Z."""
+
+ def get_steppers(self):
+ return [FakeInactiveStepper()]
diff --git a/tests/test_check_all.py b/tests/test_check_all.py
new file mode 100644
index 0000000..cac3c0c
--- /dev/null
+++ b/tests/test_check_all.py
@@ -0,0 +1,85 @@
+# Unit tests for the aggregate validation runner.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import importlib.util
+import pathlib
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+def load_script(name):
+ """Load a script module from the repository scripts directory."""
+ path = ROOT / 'scripts' / name
+ spec = importlib.util.spec_from_file_location(path.stem, path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+check_all = load_script('check_all.py')
+
+
+class CheckAllTest(unittest.TestCase):
+ """Covers the aggregate validation command runner."""
+
+ def test_command_text_includes_env_prefix(self):
+ text = check_all.command_text(
+ ('python3', '-m', 'unittest'),
+ {'PYTHONDONTWRITEBYTECODE': '1'})
+ self.assertEqual(
+ text,
+ 'env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest')
+
+ def test_run_all_stops_at_first_failure(self):
+ calls = []
+ old_run_command = check_all.run_command
+
+ def fake_run_command(command, env_updates=None):
+ calls.append((command, env_updates))
+ if command == ('second',):
+ return 7
+ return 0
+
+ try:
+ check_all.run_command = fake_run_command
+ ret = check_all.run_all((
+ (('first',), None),
+ (('second',), {'A': 'B'}),
+ (('third',), None),
+ ))
+ finally:
+ check_all.run_command = old_run_command
+ self.assertEqual(ret, 7)
+ self.assertEqual(calls, [
+ (('first',), None),
+ (('second',), {'A': 'B'}),
+ ])
+
+ def test_compileall_targets_project_python_paths(self):
+ compile_commands = [
+ command for command, _env in check_all.COMMANDS
+ if '-m' in command and 'compileall' in command
+ ]
+ self.assertEqual(len(compile_commands), 1)
+ self.assertNotIn('.', compile_commands[0])
+ for path in ['z_calibration.py', 'klipper_compat.py',
+ 'scripts', 'tests']:
+ self.assertIn(path, compile_commands[0])
+
+ def test_compileall_redirects_pycache_outside_repo(self):
+ compile_envs = [
+ env for command, env in check_all.COMMANDS
+ if '-m' in command and 'compileall' in command
+ ]
+ self.assertEqual(len(compile_envs), 1)
+ self.assertEqual(
+ compile_envs[0]['PYTHONPYCACHEPREFIX'],
+ '/tmp/klipper_z_calibration-pycache')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_firmware_compat.py b/tests/test_firmware_compat.py
new file mode 100644
index 0000000..95e39cf
--- /dev/null
+++ b/tests/test_firmware_compat.py
@@ -0,0 +1,200 @@
+# Unit tests for firmware compatibility checkout orchestration.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import contextlib
+import io
+import importlib.util
+import pathlib
+import tempfile
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+def load_script(name):
+ """Load a script module from the repository scripts directory."""
+ path = ROOT / 'scripts' / name
+ spec = importlib.util.spec_from_file_location(path.stem, path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+firmware_compat = load_script('check_firmware_compat.py')
+
+
+class FirmwareCompatTest(unittest.TestCase):
+ """Covers firmware checkout orchestration and tag parsing."""
+
+ def test_version_key_sorts_semver_tags(self):
+ tags = ['v1.10.0', 'v1.2.9', 'v1.2.10']
+ self.assertEqual(
+ sorted(tags, key=firmware_compat.version_key),
+ ['v1.2.9', 'v1.2.10', 'v1.10.0'])
+
+ def test_latest_klipper_tag_parses_remote_refs(self):
+ old_run = firmware_compat.run
+
+ class Result:
+ stdout = (
+ "aaaaaaaa\trefs/tags/v0.12.0\n"
+ "bbbbbbbb\trefs/tags/v0.13.1\n"
+ "cccccccc\trefs/tags/not-a-release\n"
+ )
+
+ try:
+ firmware_compat.run = lambda *args, **kwargs: Result()
+ self.assertEqual(firmware_compat.latest_klipper_tag(), 'v0.13.1')
+ finally:
+ firmware_compat.run = old_run
+
+ def test_run_checks_clones_expected_targets(self):
+ calls = []
+ checks = []
+ output = io.StringIO()
+ old_latest = firmware_compat.latest_klipper_tag
+ old_clone = firmware_compat.clone_or_update
+ old_check = firmware_compat.check_contract
+ try:
+ firmware_compat.latest_klipper_tag = lambda: 'v0.13.1'
+
+ def fake_clone(path, url, ref, update=True):
+ calls.append((path.name, url, ref, update))
+
+ def fake_check(name, path):
+ checks.append((name, path.name))
+ return 0
+
+ firmware_compat.clone_or_update = fake_clone
+ firmware_compat.check_contract = fake_check
+ with tempfile.TemporaryDirectory() as tempdir:
+ with contextlib.redirect_stdout(output):
+ ret = firmware_compat.run_checks(tempdir, update=True)
+ finally:
+ firmware_compat.latest_klipper_tag = old_latest
+ firmware_compat.clone_or_update = old_clone
+ firmware_compat.check_contract = old_check
+ self.assertEqual(ret, 0)
+ self.assertEqual(calls, [
+ ('klipper-release', firmware_compat.KLIPPER_URL,
+ 'v0.13.1', True),
+ ('klipper-master', firmware_compat.KLIPPER_URL,
+ 'master', True),
+ ('kalico-main', firmware_compat.KALICO_URL,
+ 'main', True),
+ ])
+ self.assertEqual(checks, [
+ ('klipper-release', 'klipper-release'),
+ ('klipper-master', 'klipper-master'),
+ ('kalico-main', 'kalico-main'),
+ ])
+ self.assertIn('Firmware compatibility result: OK',
+ output.getvalue())
+
+ def test_no_update_uses_existing_checkouts_without_remote_lookup(self):
+ calls = []
+ checks = []
+ output = io.StringIO()
+ old_latest = firmware_compat.latest_klipper_tag
+ old_clone = firmware_compat.clone_or_update
+ old_check = firmware_compat.check_contract
+ try:
+ def fail_latest():
+ raise AssertionError("latest_klipper_tag should not run")
+
+ def fake_clone(path, url, ref, update=True):
+ calls.append((path.name, url, ref, update))
+
+ def fake_check(name, path):
+ checks.append((name, path.name))
+ return 0
+
+ firmware_compat.latest_klipper_tag = fail_latest
+ firmware_compat.clone_or_update = fake_clone
+ firmware_compat.check_contract = fake_check
+ with tempfile.TemporaryDirectory() as tempdir:
+ tempdir = pathlib.Path(tempdir)
+ for name in ['klipper-release', 'klipper-master',
+ 'kalico-main']:
+ (tempdir / name).mkdir()
+ with contextlib.redirect_stdout(output):
+ ret = firmware_compat.run_checks(tempdir, update=False)
+ finally:
+ firmware_compat.latest_klipper_tag = old_latest
+ firmware_compat.clone_or_update = old_clone
+ firmware_compat.check_contract = old_check
+ self.assertEqual(ret, 0)
+ self.assertEqual(calls, [
+ ('klipper-release', firmware_compat.KLIPPER_URL, None, False),
+ ('klipper-master', firmware_compat.KLIPPER_URL, None, False),
+ ('kalico-main', firmware_compat.KALICO_URL, None, False),
+ ])
+ self.assertEqual(checks, [
+ ('klipper-release', 'klipper-release'),
+ ('klipper-master', 'klipper-master'),
+ ('kalico-main', 'kalico-main'),
+ ])
+ self.assertIn('Firmware compatibility result: OK',
+ output.getvalue())
+
+ def test_no_update_requires_existing_checkouts(self):
+ old_latest = firmware_compat.latest_klipper_tag
+ try:
+ def fail_latest():
+ raise AssertionError("latest_klipper_tag should not run")
+
+ firmware_compat.latest_klipper_tag = fail_latest
+ with tempfile.TemporaryDirectory() as tempdir:
+ with self.assertRaises(RuntimeError) as err:
+ firmware_compat.run_checks(tempdir, update=False)
+ finally:
+ firmware_compat.latest_klipper_tag = old_latest
+ self.assertIn('run without --no-update first', str(err.exception))
+
+ def test_check_contract_reports_ok_line(self):
+ output = io.StringIO()
+ old_check = (
+ firmware_compat.check_klipper_contract.check_klipper_contract)
+ old_profiles = (
+ firmware_compat.check_klipper_contract.get_contract_profiles)
+ try:
+ firmware_compat.check_klipper_contract.check_klipper_contract = (
+ lambda path: [])
+ firmware_compat.check_klipper_contract.get_contract_profiles = (
+ lambda path: ['modern_probe_result_session'])
+ with contextlib.redirect_stdout(output):
+ ret = firmware_compat.check_contract(
+ 'klipper-master', pathlib.Path('/tmp/klipper'))
+ finally:
+ firmware_compat.check_klipper_contract.check_klipper_contract = (
+ old_check)
+ firmware_compat.check_klipper_contract.get_contract_profiles = (
+ old_profiles)
+ self.assertEqual(ret, 0)
+ self.assertIn('klipper-master ... ok', output.getvalue())
+ self.assertIn('modern_probe_result_session', output.getvalue())
+
+ def test_check_contract_reports_fail_line_and_errors(self):
+ output = io.StringIO()
+ old_check = (
+ firmware_compat.check_klipper_contract.check_klipper_contract)
+ try:
+ firmware_compat.check_klipper_contract.check_klipper_contract = (
+ lambda path: ['Klipper contract failed: missing marker'])
+ with contextlib.redirect_stdout(output):
+ ret = firmware_compat.check_contract(
+ 'kalico-main', pathlib.Path('/tmp/kalico'))
+ finally:
+ firmware_compat.check_klipper_contract.check_klipper_contract = (
+ old_check)
+ self.assertEqual(ret, 1)
+ self.assertIn('kalico-main ... FAIL', output.getvalue())
+ self.assertIn('- Klipper contract failed: missing marker',
+ output.getvalue())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_import_bootstrap.py b/tests/test_import_bootstrap.py
new file mode 100644
index 0000000..a1838ff
--- /dev/null
+++ b/tests/test_import_bootstrap.py
@@ -0,0 +1,70 @@
+# Unit tests for symlinked plugin import bootstrapping.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import importlib.util
+import os
+import pathlib
+import sys
+import tempfile
+import types
+import unittest
+
+from fakes import FakeMCUEndstop
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+class ImportBootstrapTest(unittest.TestCase):
+ """Covers helper module loading from a symlinked plugin entrypoint."""
+
+ def test_symlinked_main_module_imports_repo_local_compat(self):
+ old_path = list(sys.path)
+ old_mcu = sys.modules.get('mcu')
+ old_compat = sys.modules.get('klipper_compat')
+ old_module = sys.modules.get('z_calibration_symlink_test')
+ try:
+ sys.path[:] = [
+ path for path in sys.path
+ if pathlib.Path(path or os.curdir).resolve() != ROOT
+ ]
+ sys.modules['mcu'] = types.SimpleNamespace(
+ MCU_endstop=FakeMCUEndstop)
+ sys.modules.pop('klipper_compat', None)
+ sys.modules.pop('z_calibration_symlink_test', None)
+
+ with tempfile.TemporaryDirectory() as tempdir:
+ extras = pathlib.Path(tempdir) / 'klippy' / 'extras'
+ extras.mkdir(parents=True)
+ link_path = extras / 'z_calibration.py'
+ os.symlink(ROOT / 'z_calibration.py', link_path)
+
+ spec = importlib.util.spec_from_file_location(
+ 'z_calibration_symlink_test', link_path)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+
+ self.assertEqual(pathlib.Path(module.MODULE_PATH), ROOT)
+ compat_file = pathlib.Path(sys.modules['klipper_compat'].__file__)
+ self.assertEqual(compat_file.resolve(), ROOT / 'klipper_compat.py')
+ finally:
+ sys.path[:] = old_path
+ if old_mcu is None:
+ sys.modules.pop('mcu', None)
+ else:
+ sys.modules['mcu'] = old_mcu
+ if old_compat is None:
+ sys.modules.pop('klipper_compat', None)
+ else:
+ sys.modules['klipper_compat'] = old_compat
+ if old_module is None:
+ sys.modules.pop('z_calibration_symlink_test', None)
+ else:
+ sys.modules['z_calibration_symlink_test'] = old_module
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
new file mode 100644
index 0000000..64f483b
--- /dev/null
+++ b/tests/test_install_script.py
@@ -0,0 +1,146 @@
+# Unit tests for installer behavior and cleanup.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import os
+import pathlib
+import shlex
+import subprocess
+import tempfile
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+INSTALL_SH = ROOT / 'install.sh'
+
+
+def q(value):
+ """Shell-quote a value for bash snippets."""
+ return shlex.quote(str(value))
+
+
+def run_bash(script):
+ """Source install.sh and run a bash snippet in the repo root."""
+ command = ". %s\n%s" % (q(INSTALL_SH), script)
+ return subprocess.run(
+ ['bash', '-c', command],
+ cwd=str(ROOT),
+ text=True,
+ capture_output=True,
+ check=False)
+
+
+def make_klipper_tree(tempdir, kalico=False):
+ """Create a minimal fake Klipper/Kalico tree for installer tests."""
+ root = pathlib.Path(tempdir) / 'klipper'
+ (root / 'klippy' / 'extras').mkdir(parents=True)
+ if kalico:
+ (root / 'klippy' / 'plugins').mkdir(parents=True)
+ return root
+
+
+class InstallScriptTest(unittest.TestCase):
+ """Covers installer link creation and cleanup behavior."""
+
+ def test_links_stock_klipper_extra_only(self):
+ with tempfile.TemporaryDirectory() as tempdir:
+ klipper = make_klipper_tree(tempdir)
+ result = run_bash(
+ "KLIPPER_PATH=%s\n"
+ "set_install_paths\n"
+ "link_extension\n" % (q(klipper),))
+ self.assertEqual(result.returncode, 0, result.stderr)
+ link = klipper / 'klippy' / 'extras' / 'z_calibration.py'
+ self.assertTrue(link.is_symlink())
+ self.assertEqual(link.resolve(), ROOT / 'z_calibration.py')
+ self.assertFalse(
+ (klipper / 'klippy' / 'extras' / 'klipper_compat.py').exists())
+
+ def test_links_kalico_plugin_and_cleans_old_repo_links(self):
+ with tempfile.TemporaryDirectory() as tempdir:
+ klipper = make_klipper_tree(tempdir, kalico=True)
+ extras = klipper / 'klippy' / 'extras'
+ plugins = klipper / 'klippy' / 'plugins'
+ os.symlink(ROOT / 'z_calibration.py',
+ extras / 'z_calibration.py')
+ os.symlink(ROOT / 'klipper_compat.py',
+ extras / 'klipper_compat.py')
+ os.symlink(ROOT / 'klipper_compat.py',
+ plugins / 'klipper_compat.py')
+ result = run_bash(
+ "KLIPPER_PATH=%s\n"
+ "set_install_paths\n"
+ "link_extension\n" % (q(klipper),))
+ self.assertEqual(result.returncode, 0, result.stderr)
+ link = plugins / 'z_calibration.py'
+ self.assertTrue(link.is_symlink())
+ self.assertEqual(link.resolve(), ROOT / 'z_calibration.py')
+ self.assertFalse((extras / 'z_calibration.py').exists())
+ self.assertFalse((extras / 'klipper_compat.py').exists())
+ self.assertFalse((plugins / 'klipper_compat.py').exists())
+
+ def test_uninstall_removes_only_repo_owned_python_links(self):
+ with tempfile.TemporaryDirectory() as tempdir:
+ klipper = make_klipper_tree(tempdir)
+ extras = klipper / 'klippy' / 'extras'
+ os.symlink(ROOT / 'z_calibration.py',
+ extras / 'z_calibration.py')
+ regular = extras / 'klipper_compat.py'
+ regular.write_text("do not remove\n", encoding='utf-8')
+ (extras / 'z_calibration.pyc').write_text("bytecode\n",
+ encoding='utf-8')
+ result = run_bash(
+ "KLIPPER_PATH=%s\n"
+ "set_install_paths\n"
+ "uinstall\n" % (q(klipper),))
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertFalse((extras / 'z_calibration.py').exists())
+ self.assertFalse((extras / 'z_calibration.pyc').exists())
+ self.assertTrue(regular.exists())
+ self.assertEqual(regular.read_text(encoding='utf-8'),
+ "do not remove\n")
+
+ def test_uninstall_main_does_not_require_moonraker_config(self):
+ result = run_bash(
+ "verify_ready(){ echo verify; }\n"
+ "check_klipper(){ echo check_klipper; }\n"
+ "check_klipper_path(){ echo check_path; }\n"
+ "check_requirements(){ echo bad_requirements; return 42; }\n"
+ "uinstall(){ echo uninstall; }\n"
+ "restart_klipper(){ echo restart; }\n"
+ "main -u\n")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertNotIn('bad_requirements', result.stdout)
+ self.assertIn('uninstall', result.stdout)
+
+ def test_main_rejects_invalid_num_installs_before_service_checks(self):
+ for value in ['0', '-1', 'abc']:
+ with self.subTest(value=value):
+ result = run_bash(
+ "check_klipper(){ echo bad_service_check; }\n"
+ "main -n %s\n" % (q(value),))
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("-n must be a positive integer", result.stdout)
+ self.assertNotIn("bad_service_check", result.stdout)
+
+ def test_default_moonraker_config_falls_back_to_old_path(self):
+ with tempfile.TemporaryDirectory() as tempdir:
+ default = pathlib.Path(tempdir) / 'printer_data' / 'moonraker.conf'
+ fallback = pathlib.Path(tempdir) / 'klipper_config'
+ fallback.mkdir()
+ fallback_config = fallback / 'moonraker.conf'
+ fallback_config.write_text("[server]\n", encoding='utf-8')
+ result = run_bash(
+ "MOONRAKER_CONFIG=%s\n"
+ "MOONRAKER_FALLBACK=%s\n"
+ "MOONRAKER_CONFIG_CUSTOM=0\n"
+ "resolve_moonraker_config\n"
+ "printf 'selected=%%s\\n' \"$MOONRAKER_CONFIG\"\n"
+ % (q(default), q(fallback_config)))
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("selected=%s" % (fallback_config,), result.stdout)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_klipper_compat.py b/tests/test_klipper_compat.py
new file mode 100644
index 0000000..f6b3c5d
--- /dev/null
+++ b/tests/test_klipper_compat.py
@@ -0,0 +1,212 @@
+# Unit tests for Klipper/Kalico compatibility wrappers and runtime contracts.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import importlib
+import sys
+import types
+import unittest
+
+from fakes import FakeConfig, FakeError, FakeLegacyProbe, FakeMCUEndstop
+from fakes import FakePrinter, FakeProbe
+
+
+sys.modules['mcu'] = types.SimpleNamespace(MCU_endstop=FakeMCUEndstop)
+klipper_compat = importlib.import_module('klipper_compat')
+
+
+def probe_params():
+ """Return standard fake probe defaults."""
+ return {
+ 'samples': 1,
+ 'samples_tolerance': 0.1,
+ 'samples_tolerance_retries': 0,
+ 'lift_speed': 5.0,
+ 'samples_result': 'average',
+ }
+
+
+class FakeOldSessionProbe:
+ """Probe exposing the old nested probe_session fallback."""
+
+ def __init__(self):
+ self.mcu_probe = FakeMCUEndstop()
+ self.probe_session = types.SimpleNamespace(
+ start_probe_session=lambda gcmd: None,
+ end_probe_session=lambda: None)
+
+ def get_probe_params(self):
+ """Return standard fake probe defaults."""
+ return probe_params()
+
+ def get_offsets(self):
+ """Return fixed probe offsets."""
+ return (1.0, 2.0, 1.5)
+
+
+class FakeProbeEndstopWrapper:
+ """Probe endstop wrapper that may nest a usable MCU endstop."""
+
+ def __init__(self, mcu_endstop=None):
+ if mcu_endstop is not None:
+ self.mcu_endstop = mcu_endstop
+
+ def query_endstop(self, print_time):
+ """Expose query support without the full MCU endstop surface."""
+ return False
+
+
+class PrinterObjectCompatTest(unittest.TestCase):
+ """Covers object lookup wrapper behavior."""
+
+ def test_lookup_required_objects(self):
+ printer = FakePrinter()
+ compat = klipper_compat.PrinterObjectCompat(printer)
+ self.assertIs(compat.lookup_gcode(), printer.gcode)
+ self.assertIs(compat.lookup_gcode_move(), printer.gcode_move)
+ self.assertIs(compat.lookup_homing(), printer.homing)
+ self.assertIs(compat.lookup_toolhead(), printer.toolhead)
+ self.assertIs(compat.lookup_probe(), printer.objects['probe'])
+
+ def test_lookup_optional_objects_returns_none_when_absent(self):
+ printer = FakePrinter()
+ printer.objects.pop('probe')
+ compat = klipper_compat.PrinterObjectCompat(printer)
+ self.assertIsNone(compat.lookup_optional_probe())
+ self.assertIsNone(compat.lookup_safe_z_home())
+ self.assertIsNone(compat.lookup_bed_mesh())
+
+ def test_lookup_required_probe_keeps_printer_error_behavior(self):
+ printer = FakePrinter()
+ printer.objects.pop('probe')
+ compat = klipper_compat.PrinterObjectCompat(printer)
+ with self.assertRaises(KeyError):
+ compat.lookup_probe()
+
+ def test_load_startup_objects(self):
+ printer = FakePrinter()
+ config = FakeConfig(printer)
+ compat = klipper_compat.PrinterObjectCompat(printer)
+ self.assertIs(compat.load_gcode_macro(config), printer.gcode_macro)
+ self.assertIs(compat.load_query_endstops(config),
+ printer.query_endstops)
+
+
+class RuntimeContractValidatorTest(unittest.TestCase):
+ """Covers startup runtime contract validation."""
+
+ def assert_contract_fails(self, printer, probe, topic):
+ """Assert that runtime validation fails for a named topic."""
+ with self.assertRaisesRegex(FakeError, topic):
+ klipper_compat.validate_runtime_contract(
+ printer, probe, 'z_calibration')
+
+ def test_modern_probe_runtime_contract_passes(self):
+ printer = FakePrinter()
+ klipper_compat.validate_runtime_contract(
+ printer, printer.objects['probe'], 'z_calibration')
+
+ def test_legacy_multi_probe_runtime_contract_passes(self):
+ probe = FakeLegacyProbe()
+ probe.mcu_probe = FakeMCUEndstop()
+ printer = FakePrinter(probe)
+ klipper_compat.validate_runtime_contract(
+ printer, probe, 'z_calibration')
+
+ def test_wrapped_legacy_probe_endstop_runtime_contract_passes(self):
+ probe = FakeLegacyProbe()
+ probe.mcu_probe = FakeProbeEndstopWrapper(FakeMCUEndstop())
+ printer = FakePrinter(probe)
+ klipper_compat.validate_runtime_contract(
+ printer, probe, 'z_calibration')
+
+ def test_old_probe_session_runtime_contract_passes(self):
+ probe = FakeOldSessionProbe()
+ printer = FakePrinter(probe)
+ klipper_compat.validate_runtime_contract(
+ printer, probe, 'z_calibration')
+
+ def test_missing_homing_probing_move_fails_runtime_contract(self):
+ printer = FakePrinter()
+ probe = printer.objects['probe']
+ printer.homing.probing_move = None
+ self.assert_contract_fails(printer, probe, 'homing_probing_move')
+
+ def test_missing_probe_defaults_fail_runtime_contract(self):
+ probe = types.SimpleNamespace(
+ start_probe_session=lambda gcmd: None,
+ mcu_probe=FakeMCUEndstop())
+ printer = FakePrinter(probe)
+ self.assert_contract_fails(printer, probe, 'probe_defaults')
+
+ def test_missing_probe_execution_profile_fails_runtime_contract(self):
+ probe = types.SimpleNamespace(
+ get_probe_params=probe_params,
+ get_offsets=lambda: (1.0, 2.0, 1.5),
+ mcu_probe=FakeMCUEndstop())
+ printer = FakePrinter(probe)
+ self.assert_contract_fails(printer, probe,
+ 'probe_execution_profile')
+
+ def test_missing_legacy_probe_endstop_fails_runtime_contract(self):
+ probe = FakeLegacyProbe()
+ probe.mcu_probe = FakeProbeEndstopWrapper()
+ printer = FakePrinter(probe)
+ self.assert_contract_fails(printer, probe,
+ 'legacy_probe_mcu_endstop')
+
+ def test_missing_probe_endstop_query_fails_runtime_contract(self):
+ probe = FakeProbe()
+ probe.mcu_probe = types.SimpleNamespace()
+ printer = FakePrinter(probe)
+ self.assert_contract_fails(printer, probe, 'probe_endstop_query')
+
+ def test_missing_z_endstop_interface_fails_runtime_contract(self):
+ printer = FakePrinter()
+ z_endstop = types.SimpleNamespace(get_steppers=lambda: [])
+ with self.assertRaisesRegex(FakeError, 'z_endstop_probe_target'):
+ klipper_compat.validate_runtime_contract(
+ printer, printer.objects['probe'], 'z_calibration',
+ z_endstop)
+
+ def test_offset_gcode_runtime_contract_passes(self):
+ printer = FakePrinter()
+ config = FakeConfig(printer, {'offset_gcode': 'RESPOND MSG=test'})
+ offset_gcode = printer.gcode_macro.load_template(config,
+ 'offset_gcode')
+ printer.gcode_move.cmd_SET_GCODE_OFFSET = None
+ klipper_compat.validate_runtime_contract(
+ printer, printer.objects['probe'], 'z_calibration',
+ offset_gcode=offset_gcode)
+
+ def test_error_gcode_runtime_contract_passes(self):
+ printer = FakePrinter()
+ config = FakeConfig(printer, {'error_gcode': 'RESPOND MSG=test'})
+ error_gcode = printer.gcode_macro.load_template(config,
+ 'error_gcode')
+ klipper_compat.validate_runtime_contract(
+ printer, printer.objects['probe'], 'z_calibration',
+ error_gcode=error_gcode)
+
+ def test_missing_offset_gcode_template_fails_runtime_contract(self):
+ printer = FakePrinter()
+ offset_gcode = types.SimpleNamespace(
+ run_gcode_from_command=lambda context: None)
+ with self.assertRaisesRegex(FakeError, 'offset_gcode_template'):
+ klipper_compat.validate_runtime_contract(
+ printer, printer.objects['probe'], 'z_calibration',
+ offset_gcode=offset_gcode)
+
+ def test_missing_error_gcode_template_fails_runtime_contract(self):
+ printer = FakePrinter()
+ error_gcode = types.SimpleNamespace(
+ run_gcode_from_command=lambda context: None)
+ with self.assertRaisesRegex(FakeError, 'error_gcode_template'):
+ klipper_compat.validate_runtime_contract(
+ printer, printer.objects['probe'], 'z_calibration',
+ error_gcode=error_gcode)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_klipper_contract.py b/tests/test_klipper_contract.py
new file mode 100644
index 0000000..8972457
--- /dev/null
+++ b/tests/test_klipper_contract.py
@@ -0,0 +1,237 @@
+# Unit tests for Klipper source contract validation.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import importlib.util
+import pathlib
+import tempfile
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+def load_script(name):
+ """Load a script module from the repository scripts directory."""
+ path = ROOT / 'scripts' / name
+ spec = importlib.util.spec_from_file_location(path.stem, path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+check_contract = load_script('check_klipper_contract.py')
+
+
+class KlipperContractTest(unittest.TestCase):
+ """Covers synthetic Klipper source contract profiles."""
+
+ def make_tree(self, probe_source=None, homing_source=None,
+ bed_mesh_source=None, mcu_source=None,
+ gcode_macro_source=None, manual_probe_source='default'):
+ """Create a temporary synthetic Klipper source tree."""
+ tempdir = tempfile.TemporaryDirectory()
+ root = pathlib.Path(tempdir.name)
+ (root / 'klippy' / 'extras').mkdir(parents=True)
+ (root / 'klippy' / 'mcu.py').write_text(
+ mcu_source or "class MCU_endstop:\n pass\n",
+ encoding='utf-8')
+ (root / 'klippy' / 'extras' / 'probe.py').write_text(
+ probe_source or self.valid_probe_source(),
+ encoding='utf-8')
+ if manual_probe_source == 'default':
+ manual_probe_source = self.valid_manual_probe_source()
+ if manual_probe_source is not None:
+ (root / 'klippy' / 'extras' / 'manual_probe.py').write_text(
+ manual_probe_source,
+ encoding='utf-8')
+ (root / 'klippy' / 'extras' / 'homing.py').write_text(
+ homing_source or (
+ "class PrinterHoming:\n"
+ " def probing_move(self, endstop, pos, speed):\n"
+ " pass\n"),
+ encoding='utf-8')
+ (root / 'klippy' / 'extras' / 'bed_mesh.py').write_text(
+ bed_mesh_source or "zero_reference_position = None\n",
+ encoding='utf-8')
+ (root / 'klippy' / 'extras' / 'gcode_macro.py').write_text(
+ gcode_macro_source or self.valid_gcode_macro_source(),
+ encoding='utf-8')
+ return tempdir, root
+
+ def valid_probe_source(self):
+ """Return source for a modern supported probe profile."""
+ return (
+ "class PrinterProbe:\n"
+ " def start_probe_session(self, gcmd):\n"
+ " pass\n"
+ "class ProbeSession:\n"
+ " def run_probe(self, gcmd):\n"
+ " pass\n"
+ " def pull_probed_results(self):\n"
+ " pass\n"
+ " def end_probe_session(self):\n"
+ " pass\n")
+
+ def valid_manual_probe_source(self):
+ """Return source containing a ProbeResult definition."""
+ return (
+ "class ProbeResult:\n"
+ " def __init__(self):\n"
+ " self.bed_z = 0\n"
+ " self.test_x = 0\n"
+ " self.test_y = 0\n"
+ " self.test_z = 0\n")
+
+ def valid_legacy_probe_source(self):
+ """Return source for a legacy MCU endstop probe profile."""
+ return (
+ "class ProbeEndstopWrapper:\n"
+ " def __init__(self):\n"
+ " self.mcu_probe = None\n"
+ " self.get_steppers = self.mcu_probe.get_steppers\n"
+ " self.home_start = self.mcu_probe.home_start\n"
+ " self.home_wait = self.mcu_probe.home_wait\n"
+ " self.query_endstop = self.mcu_probe.query_endstop\n"
+ "class PrinterProbe:\n"
+ " def __init__(self):\n"
+ " self.mcu_probe = ProbeEndstopWrapper()\n"
+ " self.sample_count = 1\n"
+ " self.samples_tolerance = 0.1\n"
+ " self.samples_retries = 0\n"
+ " self.lift_speed = 5.0\n"
+ " self.samples_result = 'average'\n"
+ " self.z_offset = 1.0\n"
+ " def multi_probe_begin(self):\n"
+ " pass\n"
+ " def multi_probe_end(self):\n"
+ " pass\n"
+ " def get_offsets(self):\n"
+ " pass\n"
+ " def run_probe(self, gcmd):\n"
+ " pass\n"
+ " def query_probe(self):\n"
+ " return self.mcu_probe.query_endstop(0.0)\n")
+
+ def valid_gcode_macro_source(self):
+ """Return source containing the template wrapper contract."""
+ return (
+ "class TemplateWrapper:\n"
+ " def __init__(self):\n"
+ " self.create_template_context = None\n"
+ " def run_gcode_from_command(self, context=None):\n"
+ " pass\n"
+ "class PrinterGCodeMacro:\n"
+ " def load_template(self, config, option, default=None):\n"
+ " return TemplateWrapper()\n")
+
+ def valid_kalico_gcode_macro_source(self):
+ """Return source for Kalico's template wrapper layout."""
+ return (
+ "class TemplateWrapperJinja:\n"
+ " def __init__(self):\n"
+ " self.create_template_context = None\n"
+ " def run_gcode_from_command(self, context=None):\n"
+ " pass\n"
+ "class Template:\n"
+ " def __getattr__(self, name):\n"
+ " return getattr(self.function, name)\n"
+ "class PrinterGCodeMacro:\n"
+ " def load_template(self, config, option, default=None):\n"
+ " return Template()\n")
+
+ def test_valid_synthetic_tree_passes(self):
+ tempdir, root = self.make_tree()
+ with tempdir:
+ self.assertEqual(check_contract.check_klipper_contract(root), [])
+ self.assertEqual(check_contract.get_contract_profiles(root), [
+ 'modern_probe_result_session',
+ 'probe_session_xyz_list',
+ ])
+
+ def test_legacy_probe_result_location_passes(self):
+ probe_source = self.valid_manual_probe_source()
+ probe_source += self.valid_probe_source()
+ tempdir, root = self.make_tree(probe_source=probe_source,
+ manual_probe_source=None)
+ with tempdir:
+ self.assertEqual(check_contract.check_klipper_contract(root), [])
+
+ def test_probe_result_falls_back_when_manual_probe_has_no_result(self):
+ probe_source = self.valid_manual_probe_source()
+ probe_source += self.valid_probe_source()
+ tempdir, root = self.make_tree(probe_source=probe_source,
+ manual_probe_source="VALUE = 1\n")
+ with tempdir:
+ self.assertEqual(check_contract.check_klipper_contract(root), [])
+ self.assertIn('modern_probe_result_session',
+ check_contract.get_contract_profiles(root))
+
+ def test_missing_probe_test_z_uses_session_list_profile(self):
+ manual_probe_source = self.valid_manual_probe_source().replace(
+ " self.test_z = 0\n", "")
+ tempdir, root = self.make_tree(
+ manual_probe_source=manual_probe_source)
+ with tempdir:
+ self.assertEqual(check_contract.check_klipper_contract(root), [])
+ self.assertEqual(check_contract.get_contract_profiles(root),
+ ['probe_session_xyz_list'])
+
+ def test_legacy_mcu_endstop_profile_passes(self):
+ tempdir, root = self.make_tree(
+ probe_source=self.valid_legacy_probe_source(),
+ manual_probe_source=None)
+ with tempdir:
+ self.assertEqual(check_contract.check_klipper_contract(root), [])
+ self.assertEqual(check_contract.get_contract_profiles(root),
+ ['legacy_mcu_endstop_probe'])
+
+ def test_missing_start_probe_session_fails(self):
+ probe_source = self.valid_probe_source().replace(
+ " def start_probe_session(self, gcmd):\n"
+ " pass\n",
+ " pass\n")
+ tempdir, root = self.make_tree(probe_source=probe_source)
+ with tempdir:
+ errors = check_contract.check_klipper_contract(root)
+ self.assertIn(
+ 'Klipper contract failed: no supported probe compatibility profile '
+ 'found', errors)
+ self.assertTrue(any('modern_probe_result_session missing' in error
+ for error in errors))
+ self.assertTrue(any('PrinterProbe.start_probe_session not found'
+ in error for error in errors))
+
+ def test_missing_homing_probing_move_fails(self):
+ tempdir, root = self.make_tree(homing_source="class PrinterHoming:\n"
+ " pass\n")
+ with tempdir:
+ errors = check_contract.check_klipper_contract(root)
+ self.assertIn(
+ 'Klipper contract failed: homing.probing_move not found', errors)
+
+ def test_missing_mcu_endstop_fails(self):
+ tempdir, root = self.make_tree(mcu_source="class Other:\n pass\n")
+ with tempdir:
+ errors = check_contract.check_klipper_contract(root)
+ self.assertIn('Klipper contract failed: MCU_endstop not found', errors)
+
+ def test_kalico_template_layout_passes(self):
+ tempdir, root = self.make_tree(
+ gcode_macro_source=self.valid_kalico_gcode_macro_source())
+ with tempdir:
+ self.assertEqual(check_contract.check_klipper_contract(root), [])
+
+ def test_missing_template_wrapper_fails(self):
+ tempdir, root = self.make_tree(gcode_macro_source="VALUE = 1\n")
+ with tempdir:
+ errors = check_contract.check_klipper_contract(root)
+ self.assertIn(
+ 'Klipper contract failed: PrinterGCodeMacro.load_template '
+ 'not found',
+ errors)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_release_helpers.py b/tests/test_release_helpers.py
new file mode 100644
index 0000000..b7a0bc4
--- /dev/null
+++ b/tests/test_release_helpers.py
@@ -0,0 +1,162 @@
+# Unit tests for release validation and Moonraker update config helpers.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import importlib.util
+import pathlib
+import re
+import tempfile
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+def load_script(name):
+ """Load a script module from the repository scripts directory."""
+ path = ROOT / 'scripts' / name
+ spec = importlib.util.spec_from_file_location(path.stem, path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+check_release = load_script('check_release.py')
+update_moonraker = load_script('update_moonraker.py')
+
+
+class ReleaseValidationTest(unittest.TestCase):
+ """Covers release tag metadata validation."""
+
+ def test_classifies_stable_tag(self):
+ metadata = check_release.classify_tag('v1.2.3')
+ self.assertEqual(metadata['version'], '1.2.3')
+ self.assertEqual(metadata['channel'], 'stable')
+ self.assertEqual(metadata['prerelease'], 'false')
+
+ def test_classifies_beta_tag(self):
+ metadata = check_release.classify_tag('v1.2.3-beta.4')
+ self.assertEqual(metadata['version'], '1.2.3-beta.4')
+ self.assertEqual(metadata['channel'], 'beta')
+ self.assertEqual(metadata['prerelease'], 'true')
+
+ def test_rejects_invalid_tags(self):
+ for tag in ['1.2.3', 'v1.2', 'v1.2.3rc1', 'v1.2.3-beta']:
+ with self.subTest(tag=tag):
+ with self.assertRaises(check_release.ReleaseError):
+ check_release.classify_tag(tag)
+
+ def test_rejects_channel_mismatch(self):
+ metadata = check_release.classify_tag('v1.2.3-beta.1')
+ with self.assertRaises(check_release.ReleaseError):
+ check_release.validate_channel(metadata, 'stable')
+
+
+class MoonrakerUpdateTest(unittest.TestCase):
+ """Covers Moonraker update_manager config migration."""
+
+ def test_adds_new_stable_section(self):
+ updated, changed = update_moonraker.update_config_text(
+ "[server]\nhost: 0.0.0.0\n", "/repo")
+ self.assertTrue(changed)
+ self.assertIn("[update_manager z_calibration]", updated)
+ self.assertIn("channel: stable", updated)
+ self.assertIn("path: /repo", updated)
+
+ def test_migrates_existing_section_without_channel(self):
+ text = (
+ "[update_manager z_calibration]\n"
+ "type: git_repo\n"
+ "path: /repo\n"
+ "\n"
+ "[server]\n"
+ "host: 0.0.0.0\n"
+ )
+ updated, changed = update_moonraker.update_config_text(text, "/repo")
+ self.assertTrue(changed)
+ self.assertIn("type: git_repo\nchannel: stable\npath:", updated)
+
+ def test_preserves_existing_explicit_channels(self):
+ for channel in ['stable', 'beta', 'dev']:
+ text = (
+ "[update_manager z_calibration]\n"
+ "type: git_repo\n"
+ "channel: %s\n"
+ "path: /repo\n" % (channel,))
+ with self.subTest(channel=channel):
+ updated, changed = update_moonraker.update_config_text(
+ text, "/other")
+ self.assertFalse(changed)
+ self.assertEqual(updated, text)
+
+ def test_file_update_reports_changed_once(self):
+ with tempfile.TemporaryDirectory() as tempdir:
+ path = pathlib.Path(tempdir) / 'moonraker.conf'
+ path.write_text("[server]\nhost: 0.0.0.0\n", encoding='utf-8')
+ backup = path.with_name(path.name + '.bak')
+ self.assertTrue(update_moonraker.update_config_file(path, "/repo"))
+ self.assertEqual(backup.read_text(encoding='utf-8'),
+ "[server]\nhost: 0.0.0.0\n")
+ self.assertFalse(update_moonraker.update_config_file(path, "/repo"))
+
+
+class ReleaseWorkflowTest(unittest.TestCase):
+ """Covers release workflow safety properties."""
+
+ def workflow_text(self, name='release.yml'):
+ """Return the tracked GitHub release workflow text."""
+ path = ROOT / '.github' / 'workflows' / name
+ return path.read_text(encoding='utf-8')
+
+ def workflow_texts(self):
+ """Return all tracked GitHub workflow texts keyed by file name."""
+ workflow_dir = ROOT / '.github' / 'workflows'
+ return {
+ path.name: path.read_text(encoding='utf-8')
+ for path in sorted(workflow_dir.glob('*.yml'))
+ }
+
+ def test_release_ref_is_validated_before_release_checkout(self):
+ text = self.workflow_text()
+ self.assertLess(text.index('name: Validate release ref'),
+ text.index('name: Check out release tag'))
+ self.assertIn(
+ 'ref: refs/tags/${{ needs.validate-release-ref.outputs.tag }}',
+ text)
+ self.assertIn('persist-credentials: false', text)
+ self.assertIn('permissions:\n contents: read', text)
+
+ def test_checkout_credentials_are_not_persisted(self):
+ for name, text in self.workflow_texts().items():
+ for match in re.finditer(r'uses:\s+actions/checkout@', text):
+ next_step = text.find('\n - name:', match.end())
+ checkout_block = text[match.end():]
+ if next_step != -1:
+ checkout_block = text[match.end():next_step]
+ with self.subTest(workflow=name, offset=match.start()):
+ self.assertIn('persist-credentials: false',
+ checkout_block)
+
+ def test_release_publish_job_does_not_checkout_source(self):
+ text = self.workflow_text()
+ draft_release = text[text.index(' draft-release:'):]
+ self.assertNotIn('actions/checkout', draft_release)
+ self.assertIn('uses: actions/download-artifact@', draft_release)
+ self.assertIn('uses: actions/upload-artifact@', text)
+ self.assertLess(text.index('uses: actions/upload-artifact@'),
+ text.index(' draft-release:'))
+ self.assertEqual(text.count('contents: write'), 1)
+ self.assertIn('permissions:\n contents: write', draft_release)
+
+ def test_release_workflow_updates_existing_draft_assets(self):
+ text = self.workflow_text()
+ self.assertIn('gh release view "$RELEASE_TAG"', text)
+ self.assertIn(
+ 'gh release upload "$RELEASE_TAG" dist/*.tar.gz --clobber',
+ text)
+ self.assertIn('already exists and is not a draft', text)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_z_calibration.py b/tests/test_z_calibration.py
new file mode 100644
index 0000000..32facdf
--- /dev/null
+++ b/tests/test_z_calibration.py
@@ -0,0 +1,797 @@
+# Unit tests for z_calibration command behavior and calibration flow.
+#
+# Copyright (C) 2021-2026 Titus Meyer
+#
+# This file may be distributed under the terms of the GNU GPLv3 license.
+import importlib
+import sys
+import types
+import unittest
+
+from fakes import FakeConfig, FakeEmptyProbeSession, FakeError
+from fakes import FakeGcmd, FakeInactiveRail, FakeLegacyProbe
+from fakes import FakeMCUEndstop, FakeOldProbe, FakePrinter
+from fakes import FakeProbe, FakeProbeSession, FakeProbeWithProbeSession
+from fakes import FakeRail, ProbeResult
+
+
+sys.modules['mcu'] = types.SimpleNamespace(MCU_endstop=FakeMCUEndstop)
+klipper_compat = importlib.import_module('klipper_compat')
+z_calibration = importlib.import_module('z_calibration')
+
+
+def make_helper(values=None, probe=None):
+ """Create a connected helper with Z rail settings initialized."""
+ printer = FakePrinter(probe)
+ config = FakeConfig(printer, values)
+ helper = z_calibration.ZCalibrationHelper(config)
+ helper.handle_connect()
+ helper.handle_home_rails_end(None, [FakeRail()])
+ return helper, printer
+
+
+class ZCalibrationTest(unittest.TestCase):
+ """Covers plugin startup, commands, and calibration behavior."""
+
+ def test_load_config_returns_helper(self):
+ printer = FakePrinter()
+ config = FakeConfig(printer)
+ self.assertIsInstance(
+ z_calibration.load_config(config),
+ z_calibration.ZCalibrationHelper)
+
+ def test_status_reports_last_state(self):
+ helper, _printer = make_helper()
+ helper.last_state = True
+ helper.last_z_offset = 0.123
+ self.assertEqual(helper.get_status(0.0),
+ {'last_query': True, 'last_z_offset': 0.123})
+
+ def test_offset_margins_single_value_is_symmetric(self):
+ helper, _printer = make_helper({'offset_margins': '0.25'})
+ self.assertEqual(helper.offset_margins, [-0.25, 0.25])
+
+ def test_offset_margins_reject_invalid_values(self):
+ invalid_values = [
+ '-1,0,1', '1,-1', '', 'bad', 'nan,1', '-inf,1', '1,inf']
+ for raw in invalid_values:
+ with self.subTest(raw=raw):
+ printer = FakePrinter()
+ config = FakeConfig(printer, {'offset_margins': raw})
+ with self.assertRaises(FakeError):
+ z_calibration.ZCalibrationHelper(config)
+
+ def test_optional_gcode_rejects_blank_value(self):
+ for raw in ['', ' ']:
+ for option in ['offset_gcode', 'error_gcode']:
+ with self.subTest(option=option, raw=raw):
+ printer = FakePrinter()
+ config = FakeConfig(printer, {option: raw})
+ pattern = '%s .* cannot be blank' % (option,)
+ with self.assertRaisesRegex(FakeError, pattern):
+ z_calibration.ZCalibrationHelper(config)
+
+ def test_error_gcode_runs_for_early_calibration_errors(self):
+ helper, printer = make_helper({
+ 'error_gcode': 'RESPOND MSG={params.ERROR}',
+ })
+ printer.toolhead.homed_axes = 'xy'
+ with self.assertRaisesRegex(FakeError, 'must home axes first'):
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ error_template = printer.gcode_macro.templates['error_gcode']
+ self.assertEqual(error_template.calls, 1)
+ self.assertIn('must home axes first',
+ error_template.contexts[0]['params']['ERROR'])
+ self.assertEqual(printer.gcode_macro.templates['end_gcode'].calls, 0)
+
+ def test_error_gcode_failure_preserves_original_error(self):
+ helper, printer = make_helper({
+ 'error_gcode': 'RESPOND MSG={params.ERROR}',
+ })
+ error_template = printer.gcode_macro.templates['error_gcode']
+ error_template.exception = FakeError('error hook failed')
+ printer.toolhead.homed_axes = 'xy'
+ with self.assertLogs(level='ERROR') as logs:
+ with self.assertRaisesRegex(FakeError, 'must home axes first'):
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ self.assertEqual(error_template.calls, 1)
+ self.assertIn('error_gcode failed', '\n'.join(logs.output))
+
+ def test_gcode_options_load_through_shared_templates(self):
+ helper, printer = make_helper({
+ 'offset_gcode': 'RESPOND MSG=test',
+ 'error_gcode': 'RESPOND MSG=error',
+ })
+ self.assertIs(helper.start_gcode,
+ printer.gcode_macro.templates['start_gcode'])
+ self.assertIs(helper.switch_gcode,
+ printer.gcode_macro.templates['before_switch_gcode'])
+ self.assertIs(helper.end_gcode,
+ printer.gcode_macro.templates['end_gcode'])
+ self.assertIs(helper.offset_gcode,
+ printer.gcode_macro.templates['offset_gcode'])
+ self.assertIs(helper.error_gcode,
+ printer.gcode_macro.templates['error_gcode'])
+
+ def test_error_gcode_does_not_run_on_calibration_success(self):
+ session = FakeProbeSession([
+ ProbeResult(30.0, 30.0, 123.0, 29.0, 28.0, 5.0),
+ ])
+ probe = FakeProbe(session=session, offsets=(1.0, 2.0, 1.5))
+ values = {
+ 'switch_offset': '0.5',
+ 'offset_margins': '-10,10',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ 'error_gcode': 'RESPOND MSG={params.ERROR}',
+ }
+ helper, printer = make_helper(values, probe)
+ printer.homing.results = [
+ [10.0, 10.0, 1.0],
+ [20.0, 20.0, 2.0],
+ ]
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ self.assertEqual(
+ printer.gcode_macro.templates['error_gcode'].calls, 0)
+
+ def test_error_gcode_runs_after_end_gcode_for_calibration_errors(self):
+ session = FakeProbeSession([
+ ProbeResult(30.0, 30.0, 123.0, 29.0, 28.0, 5.0),
+ ])
+ probe = FakeProbe(session=session, offsets=(1.0, 2.0, 1.5))
+ helper, printer = make_helper({
+ 'switch_offset': '0.5',
+ 'offset_margins': '-1,1',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ 'error_gcode': 'RESPOND MSG={params.ERROR}',
+ }, probe)
+ printer.homing.results = [
+ [10.0, 10.0, 1.0],
+ [20.0, 20.0, 2.0],
+ ]
+ with self.assertRaisesRegex(FakeError, 'outside the configured range'):
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ error_template = printer.gcode_macro.templates['error_gcode']
+ self.assertEqual(error_template.calls, 1)
+ self.assertIn('outside the configured range',
+ error_template.contexts[0]['params']['ERROR'])
+ self.assertEqual(printer.gcode_macro.executions, [
+ 'start_gcode',
+ 'before_switch_gcode',
+ 'end_gcode',
+ 'error_gcode',
+ ])
+
+ def test_error_gcode_rawparams_contains_error_message(self):
+ helper, printer = make_helper({
+ 'error_gcode': 'RESPOND MSG={rawparams}',
+ })
+ printer.toolhead.homed_axes = 'xy'
+ with self.assertRaisesRegex(FakeError, 'must home axes first'):
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ error_template = printer.gcode_macro.templates['error_gcode']
+ self.assertIn('ERROR=', error_template.contexts[0]['rawparams'])
+ self.assertIn('must home axes first',
+ error_template.contexts[0]['rawparams'])
+
+ def test_parse_xy_rejects_malformed_gcode_parameter(self):
+ helper, _printer = make_helper()
+ gcmd = FakeGcmd(params={'NOZZLE_POSITION': '1,2,3'})
+ with self.assertRaisesRegex(FakeError,
+ 'unable to parse NOZZLE_POSITION'):
+ helper._parse_xy('NOZZLE_POSITION', '1,2,3', gcmd)
+
+ def test_parse_xy_rejects_non_finite_gcode_parameter(self):
+ helper, _printer = make_helper()
+ gcmd = FakeGcmd(params={'NOZZLE_POSITION': 'nan,1'})
+ for raw in ['nan,1', '1,inf', '-inf,1']:
+ with self.subTest(raw=raw):
+ with self.assertRaisesRegex(FakeError,
+ 'unable to parse NOZZLE_POSITION'):
+ helper._parse_xy('NOZZLE_POSITION', raw, gcmd)
+
+ def test_parse_xy_rejects_malformed_config_value(self):
+ printer = FakePrinter()
+ config = FakeConfig(printer)
+ helper = z_calibration.ZCalibrationHelper(config)
+ with self.assertRaisesRegex(FakeError,
+ 'Unable to parse bad_xy_position'):
+ helper._parse_xy('bad_xy_position', '1,2,3', config=config)
+
+ def test_parse_xy_without_context_uses_printer_config_error(self):
+ helper, _printer = make_helper()
+ with self.assertRaisesRegex(FakeError, 'Unable to parse POSITION'):
+ helper._parse_xy('POSITION', None)
+
+ def test_handle_connect_requires_probe(self):
+ printer = FakePrinter()
+ printer.objects.pop('probe')
+ config = FakeConfig(printer)
+ helper = z_calibration.ZCalibrationHelper(config)
+ with self.assertRaisesRegex(FakeError, 'A probe is needed'):
+ helper.handle_connect()
+
+ def test_handle_connect_requires_z_endstop(self):
+ printer = FakePrinter()
+ printer.query_endstops.endstops = []
+ config = FakeConfig(printer)
+ helper = z_calibration.ZCalibrationHelper(config)
+ with self.assertRaisesRegex(FakeError, 'No z-endstop found'):
+ helper.handle_connect()
+
+ def test_handle_connect_rejects_virtual_z_endstop(self):
+ printer = FakePrinter()
+ printer.query_endstops.endstops = [(object(), 'stepper_z')]
+ config = FakeConfig(printer)
+ helper = z_calibration.ZCalibrationHelper(config)
+ with self.assertRaisesRegex(FakeError, 'virtual endstop'):
+ helper.handle_connect()
+
+ def test_handle_connect_fails_on_runtime_contract_error(self):
+ probe = FakeProbe()
+ probe.mcu_probe = types.SimpleNamespace()
+ printer = FakePrinter(probe)
+ config = FakeConfig(printer)
+ helper = z_calibration.ZCalibrationHelper(config)
+ with self.assertRaisesRegex(FakeError, 'probe_endstop_query'):
+ helper.handle_connect()
+
+ def test_handle_connect_enforces_minimum_safe_z_height(self):
+ probe = FakeProbe(offsets=(0.0, 0.0, 1.0))
+ helper, _printer = make_helper(probe=probe)
+ self.assertEqual(helper.safe_z_height, 20)
+
+ def test_handle_home_rails_end_ignores_non_z_rails(self):
+ printer = FakePrinter()
+ config = FakeConfig(printer)
+ helper = z_calibration.ZCalibrationHelper(config)
+ helper.handle_home_rails_end(None, [FakeInactiveRail()])
+ self.assertIsNone(helper.z_homing)
+
+ def test_calculate_switch_offset_requires_calibration_first(self):
+ helper, _printer = make_helper({'switch_offset': '0.5'})
+ gcmd = FakeGcmd('CALCULATE_SWITCH_OFFSET')
+ with self.assertRaisesRegex(FakeError, 'must run CALIBRATE_Z first'):
+ helper.cmd_CALCULATE_SWITCH_OFFSET(gcmd)
+
+ def test_calculate_switch_offset_reports_positive_value(self):
+ helper, printer = make_helper({'switch_offset': '0.5'})
+ helper.last_z_offset = 0.2
+ printer.toolhead.position[2] = 0.25
+ gcmd = FakeGcmd('CALCULATE_SWITCH_OFFSET')
+ helper.cmd_CALCULATE_SWITCH_OFFSET(gcmd)
+ self.assertIn('new switch_offset=0.450', gcmd.responses[-1])
+
+ def test_calculate_switch_offset_reports_negative_value(self):
+ helper, printer = make_helper({'switch_offset': '0.1'})
+ helper.last_z_offset = 0.0
+ printer.toolhead.position[2] = 1.0
+ gcmd = FakeGcmd('CALCULATE_SWITCH_OFFSET')
+ helper.cmd_CALCULATE_SWITCH_OFFSET(gcmd)
+ self.assertIn('resulting switch offset is negative', gcmd.responses[-1])
+
+ def test_require_z_homed_checks_current_toolhead_state(self):
+ helper, printer = make_helper()
+ gcmd = FakeGcmd()
+ printer.toolhead.homed_axes = 'xy'
+ with self.assertRaisesRegex(FakeError, 'must home axes first'):
+ helper._require_z_homed(gcmd)
+ printer.toolhead.homed_axes = 'xyz'
+ helper._require_z_homed(gcmd)
+
+ def test_require_z_homed_checks_cached_homing_state(self):
+ helper, _printer = make_helper()
+ helper.z_homing = None
+ with self.assertRaisesRegex(FakeError, 'must home axes first'):
+ helper._require_z_homed(FakeGcmd())
+
+ def test_safe_z_height_uses_absolute_move(self):
+ helper, printer = make_helper({'safe_z_height': '8'})
+ helper._move_safe_z([0.0, 0.0, 3.0, 0.0], 4.0)
+ self.assertEqual(printer.toolhead.moves[-1], ([None, None, 8.0], 4.0))
+
+ def test_position_resolution_paths(self):
+ helper, printer = make_helper({
+ 'switch_xy_offsets': '3,4',
+ 'switch_offset': '0.5',
+ })
+ helper.nozzle_site = None
+ helper.switch_site = None
+ helper.bed_site = None
+ printer.objects['safe_z_home'] = types.SimpleNamespace(
+ home_x_pos=7.0, home_y_pos=8.0)
+ printer.objects['bed_mesh'] = types.SimpleNamespace(
+ bmc=types.SimpleNamespace(
+ probe_mgr=types.SimpleNamespace(zero_ref_pos=[9.0, 10.0])))
+ gcmd = FakeGcmd(params={
+ 'NOZZLE_POSITION': '1,2',
+ 'SWITCH_POSITION': '3,4',
+ 'BED_POSITION': '5,6',
+ 'SWITCH_OFFSET': '0.75',
+ })
+ self.assertEqual(helper._get_nozzle_site(gcmd), [1.0, 2.0, None])
+ self.assertEqual(helper._get_switch_site(gcmd, [1.0, 2.0, None]),
+ [3.0, 4.0, None])
+ self.assertEqual(helper._get_bed_site(gcmd), [5.0, 6.0, None])
+ self.assertEqual(helper._get_switch_offset(gcmd), 0.75)
+
+ empty_gcmd = FakeGcmd()
+ self.assertEqual(helper._get_nozzle_site(empty_gcmd),
+ [7.0, 8.0, None])
+ self.assertEqual(helper._get_switch_site(empty_gcmd,
+ [1.0, 2.0, None]),
+ [4.0, 6.0, None])
+ self.assertEqual(helper._get_bed_site(empty_gcmd), [9.0, 10.0])
+
+ def test_position_resolution_reports_missing_values(self):
+ helper, printer = make_helper()
+ helper.nozzle_site = None
+ helper.switch_site = None
+ helper.switch_xy_offsets = None
+ helper.bed_site = None
+ helper.switch_offset = None
+ printer.objects.pop('bed_mesh', None)
+ gcmd = FakeGcmd()
+ with self.assertRaisesRegex(FakeError, 'cannot find a nozzle'):
+ helper._get_nozzle_site(gcmd)
+ with self.assertRaisesRegex(FakeError, 'cannot find a switch position'):
+ helper._get_switch_site(gcmd, [0.0, 0.0, None])
+ with self.assertRaisesRegex(FakeError, 'cannot find a bed position'):
+ helper._get_bed_site(gcmd)
+ with self.assertRaisesRegex(FakeError, 'cannot find a switch offset'):
+ helper._get_switch_offset(gcmd)
+
+ def test_probe_moves_retracts_and_wiggles(self):
+ helper, printer = make_helper({
+ 'wiggle_xy_offsets': '0.5,-0.5',
+ 'probing_retract_dist': '1',
+ 'lift_speed': '4',
+ 'speed': '20',
+ })
+ printer.homing.results = [[5.0, 6.0, 1.0]]
+ pos = helper._probe(FakeGcmd(), helper.z_endstop, -2.0, 3.0,
+ wiggle=True)
+ self.assertEqual(pos, [5.0, 6.0, 1.0])
+ self.assertEqual(printer.toolhead.moves[-3:],
+ [([None, None, 2.0], 4.0),
+ ([5.5, 5.5, None], 20.0),
+ ([5.0, 6.0, None], 20.0)])
+
+ def test_probe_z_accuracy_reports_statistics(self):
+ helper, printer = make_helper({
+ 'nozzle_xy_position': '1,2',
+ 'samples': '3',
+ 'safe_z_height': '12',
+ 'probing_retract_dist': '0.5',
+ 'lift_speed': '4',
+ 'probing_second_speed': '2',
+ })
+ printer.homing.results = [
+ [1.0, 2.0, 0.1],
+ [1.0, 2.0, 0.3],
+ [1.0, 2.0, 0.2],
+ ]
+ gcmd = FakeGcmd('PROBE_Z_ACCURACY')
+ helper.cmd_PROBE_Z_ACCURACY(gcmd)
+ self.assertIn('maximum 0.300000', gcmd.responses[-1])
+ self.assertIn('minimum 0.100000', gcmd.responses[-1])
+ self.assertIn('median 0.200000', gcmd.responses[-1])
+
+ def test_calc_median_handles_even_and_odd_samples(self):
+ helper, _printer = make_helper()
+ self.assertEqual(helper._calc_median([[0, 0, 1], [0, 0, 3]])[2], 2.0)
+ self.assertEqual(
+ helper._calc_median([[0, 0, 3], [0, 0, 1], [0, 0, 2]])[2],
+ 2)
+
+ def test_calibration_uses_probe_session_test_z_not_bed_z(self):
+ session = FakeProbeSession([
+ ProbeResult(30.0, 30.0, 123.0, 29.0, 28.0, 5.0),
+ ])
+ probe = FakeProbe(session=session, offsets=(1.0, 2.0, 1.5))
+ values = {
+ 'switch_offset': '0.5',
+ 'offset_margins': '-10,10',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ }
+ helper, printer = make_helper(values, probe)
+ printer.homing.results = [
+ [10.0, 10.0, 1.0],
+ [20.0, 20.0, 2.0],
+ ]
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ self.assertAlmostEqual(helper.last_z_offset, 3.5)
+ self.assertEqual(printer.gcode_move.offset_commands[0], {'Z': 0.0})
+ self.assertAlmostEqual(
+ printer.gcode_move.offset_commands[1]['Z_ADJUST'], 3.5)
+ self.assertEqual(session.run_gcmds[0].params['PROBE_SPEED'], '2.0')
+ self.assertTrue(session.ended)
+
+ def test_calibration_runs_offset_gcode_when_configured(self):
+ session = FakeProbeSession([
+ ProbeResult(30.0, 30.0, 123.0, 29.0, 28.0, 5.0),
+ ])
+ probe = FakeProbe(session=session, offsets=(1.0, 2.0, 1.5))
+ values = {
+ 'switch_offset': '0.5',
+ 'offset_margins': '-10,10',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ 'offset_gcode': 'SET_GCODE_OFFSET Z_ADJUST={params.Z|float}',
+ }
+ helper, printer = make_helper(values, probe)
+ printer.homing.results = [
+ [10.0, 10.0, 1.0],
+ [20.0, 20.0, 2.0],
+ ]
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ offset_template = printer.gcode_macro.templates['offset_gcode']
+ self.assertAlmostEqual(helper.last_z_offset, 3.5)
+ self.assertEqual(printer.gcode_move.offset_commands, [])
+ self.assertEqual(offset_template.calls, 1)
+ self.assertEqual(offset_template.contexts[0]['params']['Z'], '3.5')
+ self.assertEqual(offset_template.contexts[0]['rawparams'], 'Z=3.5')
+ self.assertEqual(offset_template.contexts[0]['printer'], 'fake')
+
+ def test_calibration_uses_legacy_probe_endstop_path(self):
+ probe = FakeLegacyProbe()
+ probe.mcu_probe = FakeMCUEndstop()
+ values = {
+ 'switch_offset': '0.5',
+ 'offset_margins': '-10,10',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ }
+ helper, printer = make_helper(values, probe)
+ printer.homing.results = [
+ [10.0, 10.0, 1.0],
+ [20.0, 20.0, 2.0],
+ [29.0, 28.0, 5.0],
+ ]
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ self.assertAlmostEqual(helper.last_z_offset, 3.5)
+ self.assertEqual(probe.begin_calls, 1)
+ self.assertEqual(probe.end_calls, 1)
+
+ def test_calibration_unwraps_legacy_probe_endstop_wrapper(self):
+ raw_endstop = FakeMCUEndstop()
+ wrapper = types.SimpleNamespace(
+ query_endstop=lambda print_time: False,
+ mcu_endstop=raw_endstop)
+ probe = FakeLegacyProbe()
+ probe.mcu_probe = wrapper
+ values = {
+ 'switch_offset': '0.5',
+ 'offset_margins': '-10,10',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ }
+ helper, printer = make_helper(values, probe)
+ printer.homing.results = [
+ [10.0, 10.0, 1.0],
+ [20.0, 20.0, 2.0],
+ [29.0, 28.0, 5.0],
+ ]
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ self.assertIs(printer.homing.calls[-1][0], raw_endstop)
+ self.assertAlmostEqual(helper.last_z_offset, 3.5)
+
+ def test_calibration_rejects_missing_legacy_probe_endstop(self):
+ probe = FakeLegacyProbe()
+ probe.mcu_probe = None
+ with self.assertRaisesRegex(FakeError, 'legacy_probe_mcu_endstop'):
+ make_helper({
+ 'switch_offset': '0.5',
+ 'offset_margins': '-10,10',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ }, probe)
+
+ def test_calibration_rejects_offset_outside_margins(self):
+ session = FakeProbeSession([
+ ProbeResult(30.0, 30.0, 123.0, 29.0, 28.0, 5.0),
+ ])
+ probe = FakeProbe(session=session, offsets=(1.0, 2.0, 1.5))
+ helper, printer = make_helper({
+ 'switch_offset': '0.5',
+ 'offset_margins': '-1,1',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ }, probe)
+ printer.homing.results = [
+ [10.0, 10.0, 1.0],
+ [20.0, 20.0, 2.0],
+ ]
+ with self.assertRaisesRegex(FakeError, 'outside the configured range'):
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ self.assertFalse(printer.gcode_move.offset_commands)
+
+ def test_calibration_rejects_offset_before_running_offset_gcode(self):
+ session = FakeProbeSession([
+ ProbeResult(30.0, 30.0, 123.0, 29.0, 28.0, 5.0),
+ ])
+ probe = FakeProbe(session=session, offsets=(1.0, 2.0, 1.5))
+ helper, printer = make_helper({
+ 'switch_offset': '0.5',
+ 'offset_margins': '-1,1',
+ 'samples': '1',
+ 'samples_tolerance': '0.5',
+ 'samples_tolerance_retries': '0',
+ 'lift_speed': '10',
+ 'safe_z_height': '5',
+ 'probing_speed': '6',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '1',
+ 'nozzle_xy_position': '10,10',
+ 'switch_xy_position': '20,20',
+ 'bed_xy_position': '30,30',
+ 'offset_gcode': 'SET_GCODE_OFFSET Z_ADJUST={params.Z|float}',
+ }, probe)
+ printer.homing.results = [
+ [10.0, 10.0, 1.0],
+ [20.0, 20.0, 2.0],
+ ]
+ with self.assertRaisesRegex(FakeError, 'outside the configured range'):
+ helper.cmd_CALIBRATE_Z(FakeGcmd())
+ offset_template = printer.gcode_macro.templates['offset_gcode']
+ self.assertFalse(printer.gcode_move.offset_commands)
+ self.assertEqual(offset_template.calls, 0)
+
+ def test_probe_on_site_retries_and_uses_median(self):
+ helper, printer = make_helper({
+ 'samples': '2',
+ 'samples_result': 'median',
+ 'samples_tolerance': '0.1',
+ 'samples_tolerance_retries': '1',
+ 'probing_second_speed': '2',
+ 'probing_retract_dist': '0.5',
+ })
+ printer.homing.results = [
+ [0.0, 0.0, 1.0],
+ [0.0, 0.0, 1.5],
+ [0.0, 0.0, 2.0],
+ [0.0, 0.0, 2.05],
+ ]
+ run = z_calibration.CalibrationRun(helper, FakeGcmd())
+ result = run._probe_on_site(helper.z_endstop, [0.0, 0.0, None])
+ self.assertAlmostEqual(result, 2.025)
+
+ def test_probe_on_site_rejects_samples_outside_tolerance(self):
+ helper, printer = make_helper({
+ 'samples': '2',
+ 'samples_tolerance': '0.1',
+ 'samples_tolerance_retries': '0',
+ 'probing_second_speed': '2',
+ })
+ printer.homing.results = [
+ [0.0, 0.0, 1.0],
+ [0.0, 0.0, 1.5],
+ ]
+ run = z_calibration.CalibrationRun(helper, FakeGcmd())
+ with self.assertRaisesRegex(FakeError, 'samples exceed tolerance'):
+ run._probe_on_site(helper.z_endstop, [0.0, 0.0, None])
+
+ def test_probe_bed_first_fast_runs_single_sample_probe(self):
+ session = FakeProbeSession([
+ ProbeResult(0.0, 0.0, 0.0, 1.0, 2.0, 3.0),
+ ProbeResult(0.0, 0.0, 0.0, 1.0, 2.0, 4.0),
+ ])
+ probe = FakeProbe(session=session)
+ helper, _printer = make_helper({
+ 'probing_first_fast': 'true',
+ 'probing_speed': '10',
+ 'probing_second_speed': '2',
+ }, probe)
+ run = z_calibration.CalibrationRun(helper, FakeGcmd())
+ run.probe_compat.start()
+ self.assertEqual(run._probe_bed_on_site([1.0, 2.0, None]), 4.0)
+ self.assertEqual(session.run_gcmds[0].params['SAMPLES'], '1')
+ self.assertEqual(session.run_gcmds[1].params['PROBE_SPEED'], '2.0')
+
+ def test_check_probe_attached_rejects_triggered_probe(self):
+ probe = FakeProbe()
+ probe.mcu_probe.triggered = True
+ helper, _printer = make_helper(probe=probe)
+ run = z_calibration.CalibrationRun(helper, FakeGcmd())
+ with self.assertRaisesRegex(FakeError, 'probe switch not closed'):
+ run._check_probe_attached()
+
+ def test_probe_session_adapter_extracts_tuple_test_z(self):
+ helper, _printer = make_helper()
+ adapter = klipper_compat.ProbeCompat(
+ helper, helper.objects_compat.lookup_probe(), FakeGcmd())
+ result = ProbeResult(1.0, 2.0, 99.0, 3.0, 4.0, 5.0)
+ self.assertEqual(adapter.get_test_position(result), [3.0, 4.0, 5.0])
+
+ def test_legacy_probe_endstop_unwraps_nested_mcu_endstop(self):
+ raw_endstop = FakeMCUEndstop()
+ wrapper = types.SimpleNamespace(mcu_endstop=raw_endstop)
+ probe = FakeProbe()
+ probe.mcu_probe = wrapper
+ helper, _printer = make_helper(probe=probe)
+ adapter = klipper_compat.ProbeCompat(helper, probe, FakeGcmd())
+ self.assertIs(adapter.get_legacy_probe_endstop(), raw_endstop)
+
+ def test_probe_compat_uses_legacy_multi_probe_fallback(self):
+ helper, _printer = make_helper()
+ probe = FakeLegacyProbe()
+ adapter = klipper_compat.ProbeCompat(helper, probe, FakeGcmd())
+ adapter.start()
+ adapter.end()
+ self.assertEqual(probe.begin_calls, 1)
+ self.assertEqual(probe.end_calls, 1)
+
+ def test_probe_compat_reads_legacy_probe_defaults(self):
+ helper, _printer = make_helper()
+ defaults = klipper_compat.ProbeCompat(
+ helper, FakeOldProbe(), FakeGcmd()).get_config_defaults()
+ self.assertEqual(defaults['samples'], 2)
+ self.assertEqual(defaults['samples_result'], 'median')
+ self.assertEqual(defaults['safe_z_height'], 8.0)
+
+ def test_probe_compat_uses_probe_session_attribute_fallback(self):
+ helper, _printer = make_helper()
+ probe = FakeProbeWithProbeSession()
+ adapter = klipper_compat.ProbeCompat(helper, probe, FakeGcmd())
+ adapter.start()
+ adapter.end()
+ self.assertTrue(probe.probe_session.ended)
+
+ def test_probe_compat_reports_unsupported_endstop_query(self):
+ helper, _printer = make_helper()
+ probe = types.SimpleNamespace(mcu_probe=types.SimpleNamespace())
+ adapter = klipper_compat.ProbeCompat(helper, probe, FakeGcmd())
+ with self.assertRaisesRegex(FakeError, 'does not support'):
+ adapter.query_endstop(1.0)
+
+ def test_probe_compat_reports_empty_probe_result(self):
+ helper, _printer = make_helper()
+ probe = FakeProbe(session=FakeEmptyProbeSession())
+ adapter = klipper_compat.ProbeCompat(helper, probe, FakeGcmd())
+ adapter.start()
+ with self.assertRaisesRegex(FakeError, 'did not return a result'):
+ adapter.run_probe(1.0)
+
+ def test_probe_compat_returns_none_without_session_probe(self):
+ helper, _printer = make_helper()
+ adapter = klipper_compat.ProbeCompat(
+ helper, FakeLegacyProbe(), FakeGcmd())
+ self.assertIsNone(adapter.run_probe(1.0))
+
+ def test_probe_compat_extracts_short_probe_tuple(self):
+ helper, _printer = make_helper()
+ adapter = klipper_compat.ProbeCompat(
+ helper, helper.objects_compat.lookup_probe(), FakeGcmd())
+ self.assertEqual(adapter.get_test_position([1.0, 2.0, 3.0]),
+ [1.0, 2.0, 3.0])
+
+ def test_probe_compat_creates_gcmd_without_parameter_snapshot(self):
+ helper, _printer = make_helper({'samples_result': 'none'})
+
+ class MinimalGcmd:
+ def get_command(self):
+ return 'CALIBRATE_Z'
+
+ def error(self, message):
+ return FakeError(message)
+
+ probe = FakeProbe(session=FakeProbeSession([
+ ProbeResult(0.0, 0.0, 0.0, 1.0, 2.0, 3.0),
+ ]))
+ adapter = klipper_compat.ProbeCompat(helper, probe, MinimalGcmd())
+ adapter.start()
+ adapter.run_probe(3.0)
+ self.assertEqual(probe.session.run_gcmds[0].params['SAMPLES_RESULT'],
+ 'average')
+
+ def test_legacy_probe_endstop_reports_missing_or_direct_endstop(self):
+ helper, _printer = make_helper()
+ missing = types.SimpleNamespace(mcu_probe=None)
+ direct = types.SimpleNamespace(mcu_probe=FakeMCUEndstop())
+ self.assertIsNone(klipper_compat.ProbeCompat(
+ helper, missing, FakeGcmd()).get_legacy_probe_endstop())
+ self.assertIs(klipper_compat.ProbeCompat(
+ helper, direct, FakeGcmd()).get_legacy_probe_endstop(),
+ direct.mcu_probe)
+
+ def test_bed_mesh_compat_reads_zero_reference_paths(self):
+ compat = klipper_compat.BedMeshCompat()
+ modern = types.SimpleNamespace(
+ bmc=types.SimpleNamespace(
+ probe_mgr=types.SimpleNamespace(zero_ref_pos=[1.0, 2.0])))
+ direct = types.SimpleNamespace(
+ bmc=types.SimpleNamespace(zero_ref_pos=[3.0, 4.0]))
+ rri = types.SimpleNamespace(
+ bmc=types.SimpleNamespace(relative_reference_index=1,
+ points=[[0.0, 0.0], [5.0, 6.0]]))
+ self.assertEqual(compat.get_zero_reference_position(modern),
+ [1.0, 2.0])
+ self.assertEqual(compat.get_zero_reference_position(direct),
+ [3.0, 4.0])
+ self.assertEqual(compat.get_zero_reference_position(rri), [5.0, 6.0])
+
+ def test_bed_mesh_compat_handles_missing_reference_paths(self):
+ compat = klipper_compat.BedMeshCompat()
+ self.assertIsNone(compat.get_zero_reference_position(None))
+ self.assertIsNone(compat.get_zero_reference_position(
+ types.SimpleNamespace()))
+ self.assertIsNone(compat.get_zero_reference_position(
+ types.SimpleNamespace(bmc=types.SimpleNamespace())))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/z_calibration.py b/z_calibration.py
index a5eba88..cd6e9d0 100644
--- a/z_calibration.py
+++ b/z_calibration.py
@@ -1,31 +1,43 @@
-# Klipper plugin for a self-calibrating Z offset.
+# Klipper plugin entrypoint for automatic dockable-probe Z calibration.
#
-# Copyright (C) 2021-2025 Titus Meyer
+# Copyright (C) 2021-2026 Titus Meyer
#
# This file may be distributed under the terms of the GNU GPLv3 license.
import logging
-from mcu import MCU_endstop
+import math
+import os
+import sys
+
+# Only this file is linked into Klipper. Resolve the symlink so helper modules
+# load from the repository checkout.
+MODULE_PATH = os.path.dirname(os.path.realpath(__file__))
+if MODULE_PATH not in sys.path:
+ sys.path.insert(0, MODULE_PATH)
+
+from klipper_compat import BedMeshCompat, GCodeOffsetCompat, HomingCompat
+from klipper_compat import PrinterObjectCompat, ProbeCompat, ToolheadCompat
+from klipper_compat import run_gcode_template, validate_runtime_contract
class ZCalibrationHelper:
+ """Owns plugin configuration, startup state, and G-Code commands."""
+
def __init__(self, config):
self.state = None
self.z_endstop = None
self.z_homing = None
self.last_state = False
- self.last_z_offset = 0.
+ self.last_z_offset = None
self.position_z_endstop = None
- self.config = config
+ self.name = config.get_name()
self.printer = config.get_printer()
+ self.objects_compat = PrinterObjectCompat(self.printer)
+ self.bed_mesh_compat = BedMeshCompat()
+ self.homing_compat = HomingCompat(self.printer)
+ self.toolhead_compat = ToolheadCompat(self.printer)
self.switch_offset = config.getfloat('switch_offset', None, above=0.)
- # TODO: remove: max_deviation is deprecated
- self.max_deviation = config.getfloat('max_deviation', None, above=0.)
- config.deprecate('max_deviation')
- self.offset_margins = self._get_offset_margins('offset_margins',
- '-1.0,1.0')
+ self.offset_margins = self._get_offset_margins(
+ config, 'offset_margins', '-1.0,1.0')
self.speed = config.getfloat('speed', 50.0, above=0.)
- # TODO: remove: clearance is deprecated
- self.clearance = config.getfloat('clearance', None, above=0.)
- config.deprecate('clearance')
self.safe_z_height = config.getfloat('safe_z_height', None, above=0.)
self.samples = config.getint('samples', None, minval=1)
self.tolerance = config.getfloat('samples_tolerance', None, above=0.)
@@ -42,24 +54,29 @@ def __init__(self, config):
None, above=0.)
self.position_min = config.getfloat('position_min', None)
self.first_fast = config.getboolean('probing_first_fast', False)
- self.nozzle_site = self._get_xy("nozzle_xy_position", True)
- self.switch_site = self._get_xy("switch_xy_position", True)
- self.switch_xy_offsets = self._get_xy("switch_xy_offsets", True)
- self.bed_site = self._get_xy("bed_xy_position", True)
- self.wiggle_offsets = self._get_xy("wiggle_xy_offsets", True)
- gcode_macro = self.printer.load_object(config, 'gcode_macro')
- self.start_gcode = gcode_macro.load_template(config, 'start_gcode', '')
- self.switch_gcode = gcode_macro.load_template(config,
- 'before_switch_gcode',
- '')
- self.end_gcode = gcode_macro.load_template(config, 'end_gcode', '')
- self.query_endstops = self.printer.load_object(config,
- 'query_endstops')
+ self.nozzle_site = self._get_xy(config, "nozzle_xy_position", True)
+ self.switch_site = self._get_xy(config, "switch_xy_position", True)
+ self.switch_xy_offsets = self._get_xy(
+ config, "switch_xy_offsets", True)
+ self.bed_site = self._get_xy(config, "bed_xy_position", True)
+ self.wiggle_offsets = self._get_xy(config, "wiggle_xy_offsets", True)
+ gcode_macro = self.objects_compat.load_gcode_macro(config)
+ self.start_gcode = self._load_gcode_template(config, gcode_macro,
+ 'start_gcode')
+ self.switch_gcode = self._load_gcode_template(
+ config, gcode_macro, 'before_switch_gcode')
+ self.end_gcode = self._load_gcode_template(config, gcode_macro,
+ 'end_gcode')
+ self.offset_gcode = self._load_optional_gcode_template(
+ config, gcode_macro, 'offset_gcode')
+ self.error_gcode = self._load_optional_gcode_template(
+ config, gcode_macro, 'error_gcode')
+ self.query_endstops = self.objects_compat.load_query_endstops(config)
self.printer.register_event_handler("klippy:connect",
self.handle_connect)
self.printer.register_event_handler("homing:home_rails_end",
self.handle_home_rails_end)
- self.gcode = self.printer.lookup_object('gcode')
+ self.gcode = self.objects_compat.lookup_gcode()
self.gcode.register_command('CALIBRATE_Z', self.cmd_CALIBRATE_Z,
desc=self.cmd_CALIBRATE_Z_help)
self.gcode.register_command('PROBE_Z_ACCURACY',
@@ -68,108 +85,154 @@ def __init__(self, config):
self.gcode.register_command('CALCULATE_SWITCH_OFFSET',
self.cmd_CALCULATE_SWITCH_OFFSET,
desc=self.cmd_CALCULATE_SWITCH_OFFSET_help)
+ # Configuration parsing helpers
+ def _load_gcode_template(self, config, gcode_macro, name):
+ """Load a G-Code template that defaults to an empty no-op."""
+ return gcode_macro.load_template(config, name, '')
+
+ def _load_optional_gcode_template(self, config, gcode_macro, name):
+ """Load an optional G-Code template, rejecting explicit blanks."""
+ value = config.get(name, None)
+ if value is None:
+ return None
+ if not value.strip():
+ raise config.error("%s in %s cannot be blank" % (name, self.name))
+ return gcode_macro.load_template(config, name)
+
+ def _get_xy(self, config, name, optional=False):
+ """Read an optional `x,y` config value as a Klipper coordinate."""
+ if optional and config.get(name, None) is None:
+ return None
+ return self._parse_xy(name, config.get(name), config=config)
+
+ def _parse_xy(self, name, site, gcmd=None, config=None):
+ """Parse an `x,y` value and report errors in the caller's context."""
+ try:
+ x_pos, y_pos = site.split(',')
+ return [self._parse_finite_float(x_pos),
+ self._parse_finite_float(y_pos),
+ None]
+ except (AttributeError, TypeError, ValueError):
+ if gcmd is not None:
+ raise gcmd.error("%s: unable to parse %s"
+ % (gcmd.get_command(), name))
+ if config is not None:
+ raise config.error("Unable to parse %s in %s"
+ % (name, self.name))
+ raise self.printer.config_error("Unable to parse %s in %s"
+ % (name, self.name))
+
+ def _parse_finite_float(self, raw_value):
+ """Parse a float and reject NaN or infinite values."""
+ value = float(raw_value)
+ if not math.isfinite(value):
+ raise ValueError()
+ return value
+
+ def _get_offset_margins(self, config, name, default):
+ """Parse offset margins as symmetric or explicit min/max bounds."""
+ try:
+ margins = [self._parse_finite_float(val.strip())
+ for val in config.get(name, default).split(',')]
+ if len(margins) == 1:
+ val = abs(margins[0])
+ margins[0] = -val
+ margins.append(val)
+ elif len(margins) != 2:
+ raise ValueError()
+ if margins[0] > margins[1]:
+ raise ValueError()
+ return margins
+ except (AttributeError, TypeError, ValueError):
+ raise config.error("Unable to parse %s in %s"
+ % (name, self.name))
+ # Klipper lifecycle and status
def get_status(self, eventtime):
+ """Expose last calibration state through Klipper's status API."""
return {'last_query': self.last_state,
'last_z_offset': self.last_z_offset}
+
def handle_connect(self):
- # get endstop
- for endstop, name in self.query_endstops.endstops:
- if name == 'stepper_z' or name == 'z':
- # check for virtual endstop on z
- if not isinstance(endstop, MCU_endstop):
- raise self.printer.config_error("A virtual endstop for z"
- " is not supported for %s"
- % (self.config.get_name()))
- self.z_endstop = EndstopWrapper(endstop)
- if self.z_endstop is None:
- raise self.printer.config_error("No z-endstop found for %s"
- % (self.config.get_name()))
+ """Resolve required printer objects once Klipper is connected."""
+ self.z_endstop = self.homing_compat.get_z_endstop(
+ self.query_endstops, self.name)
# get probing settings
- probe = self.printer.lookup_object('probe', default=None)
+ probe = self.objects_compat.lookup_optional_probe()
if probe is None:
raise self.printer.config_error("A probe is needed for %s"
- % (self.config.get_name()))
- # TODO: remove: deprecated since 2024-06-10
- if hasattr(probe, 'sample_count'):
- if self.samples is None:
- self.samples = probe.sample_count
- if self.tolerance is None:
- self.tolerance = probe.samples_tolerance
- if self.retries is None:
- self.retries = probe.samples_retries
- if self.lift_speed is None:
- self.lift_speed = probe.lift_speed
- if self.samples_result is None:
- self.samples_result = probe.samples_result
- if self.safe_z_height is None:
- self.safe_z_height = probe.z_offset * 2
- else:
- probe_params = probe.get_probe_params()
- if self.samples is None:
- self.samples = probe_params['samples']
- if self.tolerance is None:
- self.tolerance = probe_params['samples_tolerance']
- if self.retries is None:
- self.retries = probe_params['samples_tolerance_retries']
- if self.lift_speed is None:
- self.lift_speed = probe_params['lift_speed']
- if self.samples_result is None:
- self.samples_result = probe_params['samples_result']
- if self.safe_z_height is None:
- self.safe_z_height = probe.get_offsets()[2] * 2
- # TODO: remove: clearance is deprecated
- if self.clearance is not None and self.clearance == 0:
- self.clearance = 20 # defaults to 20mm
+ % (self.name,))
+ validate_runtime_contract(self.printer, probe, self.name,
+ self.z_endstop, self.offset_gcode,
+ self.error_gcode)
+ probe_defaults = ProbeCompat(self, probe).get_config_defaults()
+ if self.samples is None:
+ self.samples = probe_defaults['samples']
+ if self.tolerance is None:
+ self.tolerance = probe_defaults['samples_tolerance']
+ if self.retries is None:
+ self.retries = probe_defaults['samples_tolerance_retries']
+ if self.lift_speed is None:
+ self.lift_speed = probe_defaults['lift_speed']
+ if self.samples_result is None:
+ self.samples_result = probe_defaults['samples_result']
+ if self.safe_z_height is None:
+ self.safe_z_height = probe_defaults['safe_z_height']
if self.safe_z_height < 3:
self.safe_z_height = 20 # defaults to 20mm
+
def handle_home_rails_end(self, homing_state, rails):
+ """Cache Z rail homing settings after Klipper homes rails."""
# get z homing position
for rail in rails:
- if rail.get_steppers()[0].is_active_axis('z'):
- # get homing settings from z rail
- self.z_homing = rail.position_endstop
- if self.probing_speed is None:
- self.probing_speed = rail.homing_speed
- if self.second_speed is None:
- self.second_speed = rail.second_homing_speed
- if self.retract_dist is None:
- self.retract_dist = rail.homing_retract_dist
- if self.position_min is None:
- self.position_min = rail.position_min
- self.position_z_endstop = rail.position_endstop
- def _build_config(self):
- pass
+ settings = self.homing_compat.get_z_rail_settings(rail)
+ if settings is None:
+ continue
+ # get homing settings from z rail
+ self.z_homing = settings['position_endstop']
+ if self.probing_speed is None:
+ self.probing_speed = settings['homing_speed']
+ if self.second_speed is None:
+ self.second_speed = settings['second_homing_speed']
+ if self.retract_dist is None:
+ self.retract_dist = settings['homing_retract_dist']
+ if self.position_min is None:
+ self.position_min = settings['position_min']
+ self.position_z_endstop = settings['position_endstop']
+ # G-Code command handlers
cmd_CALIBRATE_Z_help = ("Automatically calibrates the nozzle offset"
" to the print surface")
def cmd_CALIBRATE_Z(self, gcmd):
+ """Run the full nozzle, switch, and bed probe calibration flow."""
self.last_state = False
- if self.z_homing is None:
- raise gcmd.error("%s: must home axes first" % (gcmd.get_command()))
- nozzle_site = self._get_nozzle_site(gcmd)
- switch_site = self._get_switch_site(gcmd, nozzle_site)
- bed_site = self._get_bed_site(gcmd)
- switch_offset = self._get_switch_offset(gcmd)
- self._log_params(gcmd, switch_offset, nozzle_site, switch_site,
- bed_site)
- state = CalibrationState(self, gcmd)
- state.calibrate_z(switch_offset, nozzle_site, switch_site, bed_site)
+ try:
+ self._require_z_homed(gcmd)
+ nozzle_site = self._get_nozzle_site(gcmd)
+ switch_site = self._get_switch_site(gcmd, nozzle_site)
+ bed_site = self._get_bed_site(gcmd)
+ switch_offset = self._get_switch_offset(gcmd)
+ self._log_params(gcmd, switch_offset, nozzle_site, switch_site,
+ bed_site)
+ run = CalibrationRun(self, gcmd)
+ run.calibrate_z(switch_offset, nozzle_site, switch_site, bed_site)
+ except Exception as err:
+ self._run_error_gcode(err)
+ raise
cmd_PROBE_Z_ACCURACY_help = ("Probe Z-Endstop accuracy at"
" Nozzle-Endstop position")
def cmd_PROBE_Z_ACCURACY(self, gcmd):
- if self.z_homing is None:
- raise gcmd.error("%s: must home axes first" % (gcmd.get_command()))
+ """Sample the calibration endstop and report repeatability stats."""
+ self._require_z_homed(gcmd)
speed = gcmd.get_float("PROBE_SPEED", self.second_speed, above=0.)
lift_speed = gcmd.get_float("LIFT_SPEED", self.lift_speed, above=0.)
sample_count = gcmd.get_int("SAMPLES", self.samples, minval=1)
sample_retract_dist = gcmd.get_float("SAMPLE_RETRACT_DIST",
self.retract_dist, above=0.)
nozzle_site = self._get_nozzle_site(gcmd)
- toolhead = self.printer.lookup_object('toolhead')
- pos = toolhead.get_position()
+ pos = self.toolhead_compat.get_position()
self._move_safe_z(pos, lift_speed)
# move to z-endstop position
self._move(list(nozzle_site), self.speed)
- pos = toolhead.get_position()
+ pos = self.toolhead_compat.get_position()
gcmd.respond_info("%s at X:%.3f Y:%.3f Z:%.3f"
" (samples=%d retract=%.3f"
" speed=%.1f lift_speed=%.1f)\n"
@@ -180,7 +243,8 @@ def cmd_PROBE_Z_ACCURACY(self, gcmd):
positions = []
while len(positions) < sample_count:
# Probe position
- pos = self._probe(gcmd, self.z_endstop, self.position_min, speed)
+ pos = self._probe(gcmd, self.z_endstop, self.position_min, speed,
+ retract=False)
positions.append(pos)
# Retract
liftpos = [None, None, pos[2] + sample_retract_dist]
@@ -201,16 +265,16 @@ def cmd_PROBE_Z_ACCURACY(self, gcmd):
"%s: probe z accuracy results: maximum %.6f, minimum %.6f,"
" range %.6f, average %.6f, median %.6f, standard deviation %.6f"
% (gcmd.get_command(), max_value, min_value, range_value,
- avg_value, median, sigma))
+ avg_value, median, sigma))
cmd_CALCULATE_SWITCH_OFFSET_help = ("Calculates a switch_offset based on"
" the current z position")
def cmd_CALCULATE_SWITCH_OFFSET(self, gcmd):
+ """Estimate a new switch_offset from the last calibration result."""
if self.last_z_offset is None:
raise gcmd.error("%s: must run CALIBRATE_Z first"
% (gcmd.get_command()))
switch_offset = self._get_switch_offset(gcmd)
- toolhead = self.printer.lookup_object('toolhead')
- pos = toolhead.get_position()
+ pos = self.toolhead_compat.get_position()
new_switch_offset = switch_offset - (pos[2] - self.last_z_offset)
if new_switch_offset > 0.0:
gcmd.respond_info("%s: switch_offset=%.3f - (current_z=%.3f -"
@@ -222,9 +286,11 @@ def cmd_CALCULATE_SWITCH_OFFSET(self, gcmd):
" Either the nozzle is still too far away or"
" something else is wrong..."
% (gcmd.get_command()))
+ # Command parameter and position resolution
def _get_nozzle_site(self, gcmd):
+ """Resolve the nozzle endstop XY position for this command."""
nozzle_param = gcmd.get("NOZZLE_POSITION", "")
- safe_z_home = self.printer.lookup_object('safe_z_home', default=None)
+ safe_z_home = self.objects_compat.lookup_safe_z_home()
# from NOZZLE_POSITION parameter
if nozzle_param:
return self._parse_xy("NOZZLE_POSITION", nozzle_param, gcmd)
@@ -237,8 +303,9 @@ def _get_nozzle_site(self, gcmd):
raise gcmd.error("%s: cannot find a nozzle position! Either configure"
" the nozzle_xy_position for %s, the [safe_z_home],"
" or use the NOZZLE_POSITION parameter."
- % (gcmd.get_command(), self.config.get_name()))
+ % (gcmd.get_command(), self.name))
def _get_switch_site(self, gcmd, nozzle_site):
+ """Resolve the switch body XY position for this command."""
switch_param = gcmd.get("SWITCH_POSITION", "")
# from SWITCH_POSITION parameter
if switch_param:
@@ -254,10 +321,11 @@ def _get_switch_site(self, gcmd, nozzle_site):
raise gcmd.error("%s: cannot find a switch position! Either configure"
" the switch_xy_position or the switch_xy_offsets for"
" %s or use the SWITCH_POSITION parameter."
- % (gcmd.get_command(), self.config.get_name()))
+ % (gcmd.get_command(), self.name))
def _get_bed_site(self, gcmd):
+ """Resolve the bed probing XY position for this command."""
bed_param = gcmd.get("BED_POSITION", "")
- mesh = self.printer.lookup_object('bed_mesh', default=None)
+ mesh = self.objects_compat.lookup_bed_mesh()
# from BED_POSITION parameter
if bed_param:
return self._parse_xy("BED_POSITION", bed_param, gcmd)
@@ -265,25 +333,16 @@ def _get_bed_site(self, gcmd):
if self.bed_site is not None:
return self.bed_site
# from mesh's zero reference position
- if mesh is not None:
- if (hasattr(mesh.bmc, 'probe_mgr')
- and mesh.bmc.probe_mgr.zero_ref_pos is not None):
- return mesh.bmc.probe_mgr.zero_ref_pos
- elif (hasattr(mesh.bmc, 'zero_ref_pos')
- and mesh.bmc.zero_ref_pos is not None):
- # TODO: remove - deprecated since 2024-06
- return mesh.bmc.zero_ref_pos
- elif (hasattr(mesh.bmc, 'relative_reference_index')
- and mesh.bmc.relative_reference_index is not None):
- # TODO: remove: trying to read the deprecated rri
- rri = mesh.bmc.relative_reference_index
- return mesh.bmc.points[rri]
+ bed_site = self.bed_mesh_compat.get_zero_reference_position(mesh)
+ if bed_site is not None:
+ return bed_site
raise gcmd.error("%s: cannot find a bed position! Either configure the"
" bed_xy_position for %s, the mesh's"
" zero_reference_position, or use the NOZZLE_POSITION"
" parameter."
- % (gcmd.get_command(), self.config.get_name()))
+ % (gcmd.get_command(), self.name))
def _get_switch_offset(self, gcmd):
+ """Resolve switch_offset from G-Code parameter or config."""
# from SWITCH_OFFSET parameter
if gcmd.get("SWITCH_OFFSET", ""):
return gcmd.get_float("SWITCH_OFFSET", None, above=0.)
@@ -293,73 +352,60 @@ def _get_switch_offset(self, gcmd):
raise gcmd.error("%s: cannot find a switch offset! Either configure"
" the switch_offset for %s, or use the SWITCH_OFFSET"
" parameter."
- % (gcmd.get_command(), self.config.get_name()))
- def _get_xy(self, name, optional=False):
- if optional and self.config.get(name, None) is None:
- return None
- else:
- return self._parse_xy(name, self.config.get(name))
- def _parse_xy(self, name, site, gcmd=None):
- try:
- x_pos, y_pos = site.split(',')
- return [float(x_pos), float(y_pos), None]
- except:
- if gcmd is not None:
- raise gcmd.error("%s: unable to parse %s"
- % (gcmd.get_command(), name))
- else:
- raise self.config.error("Unable to parse %s in %s"
- % (name, self.config.get_name()))
- def _get_offset_margins(self, name, default):
+ % (gcmd.get_command(), self.name))
+ def _run_error_gcode(self, err):
+ """Run the configured error hook without masking the original error."""
+ if self.error_gcode is None:
+ return
try:
- margins = self.config.get(name, default).split(',')
- for i, val in enumerate(margins):
- margins[i] = float(val)
- if len(margins) == 1:
- val = abs(margins[0])
- margins[0] = -val
- margins.append(val)
- return margins
- except:
- raise self.config.error("Unable to parse %s in %s"
- % (name, self.config.get_name()))
- def _probe(self, gcmd, mcu_endstop, z_position, speed, wiggle=False):
- toolhead = self.printer.lookup_object('toolhead')
- pos = toolhead.get_position()
- pos[2] = z_position
- # probe
- phoming = self.printer.lookup_object('homing')
- curpos = phoming.probing_move(mcu_endstop, pos, speed)
- # retract
+ run_gcode_template(self.error_gcode, {'ERROR': err})
+ except Exception:
+ logging.exception("error_gcode failed")
+ # Movement and probing primitives
+ def _probe(self, gcmd, mcu_endstop, z_position, speed, wiggle=False,
+ retract=True):
+ """Probe a given endstop at the current XY position."""
+ pos = self.toolhead_compat.get_position()
+ pos[2] = z_position
+ # probe
+ curpos = self.homing_compat.probing_move(mcu_endstop, pos, speed)
+ # retract
+ if retract:
self._move([None, None, curpos[2] + self.retract_dist],
self.lift_speed)
- if wiggle and self.wiggle_offsets is not None:
- self._move([curpos[0] + self.wiggle_offsets[0],
- curpos[1] + self.wiggle_offsets[1],
- None],
- self.speed)
- self._move([curpos[0], curpos[1], None], self.speed)
- self.gcode.respond_info("%s: probe at %.3f,%.3f is z=%.6f"
- % (gcmd.get_command(), curpos[0],
- curpos[1], curpos[2]))
- return curpos
+ if wiggle and self.wiggle_offsets is not None:
+ self._move([curpos[0] + self.wiggle_offsets[0],
+ curpos[1] + self.wiggle_offsets[1],
+ None],
+ self.speed)
+ self._move([curpos[0], curpos[1], None], self.speed)
+ self.gcode.respond_info("%s: probe at %.3f,%.3f is z=%.6f"
+ % (gcmd.get_command(), curpos[0],
+ curpos[1], curpos[2]))
+ return curpos
+ def _require_z_homed(self, gcmd):
+ """Reject commands until Z homing state is known and current."""
+ if self.z_homing is None:
+ raise gcmd.error("%s: must home axes first" % (gcmd.get_command()))
+ if not self.toolhead_compat.is_axis_homed('z'):
+ raise gcmd.error("%s: must home axes first" % (gcmd.get_command()))
def _move(self, coord, speed):
- self.printer.lookup_object('toolhead').manual_move(coord, speed)
+ """Move through Klipper's toolhead wrapper."""
+ self.toolhead_compat.manual_move(coord, speed)
+
def _move_safe_z(self, pos, lift_speed):
- # TODO: remove: clearance is deprecated
- if self.clearance is not None:
- if pos[2] < self.clearance:
- # no clearance, better to move up (relative)
- self._move([None, None, pos[2] + self.clearance], lift_speed)
- else:
- if pos[2] < self.safe_z_height:
- # no safe z position, better to move up (absolute)
- self._move([None, None, self.safe_z_height], lift_speed)
+ """Lift to safe_z_height when the current Z is below it."""
+ if pos[2] < self.safe_z_height:
+ # no safe z position, better to move up (absolute)
+ self._move([None, None, self.safe_z_height], lift_speed)
+ # Calculation and logging helpers
def _calc_mean(self, positions):
+ """Return the coordinate-wise mean of sampled positions."""
count = float(len(positions))
return [sum([pos[i] for pos in positions]) / count
for i in range(3)]
def _calc_median(self, positions):
+ """Return the median Z sample, averaging the middle pair if needed."""
z_sorted = sorted(positions, key=(lambda p: p[2]))
middle = len(positions) // 2
if (len(positions) & 1) == 1:
@@ -369,6 +415,7 @@ def _calc_median(self, positions):
return self._calc_mean(z_sorted[middle-1:middle+1])
def _log_params(self, gcmd, switch_offset, nozzle_site, switch_site,
bed_site):
+ """Write the effective calibration parameters to the Klipper log."""
logging.info("%s: switch_offset=%.3f, offset_margins=%.3f,%.3f,"
" speed=%.3f, samples=%i, tolerance=%.3f, retries=%i,"
" samples_result=%s, lift_speed=%.3f, safe_z_height=%.3f,"
@@ -386,30 +433,29 @@ def _log_params(self, gcmd, switch_offset, nozzle_site, switch_site,
self.position_min, nozzle_site[0], nozzle_site[1],
switch_site[0], switch_site[1], bed_site[0],
bed_site[1]))
-class EndstopWrapper:
- def __init__(self, endstop):
- self.mcu_endstop = endstop
- # Wrappers
- self.get_mcu = self.mcu_endstop.get_mcu
- self.add_stepper = self.mcu_endstop.add_stepper
- self.get_steppers = self.mcu_endstop.get_steppers
- self.home_start = self.mcu_endstop.home_start
- self.home_wait = self.mcu_endstop.home_wait
- self.query_endstop = self.mcu_endstop.query_endstop
-class CalibrationState:
+class CalibrationRun:
+ """Executes one CALIBRATE_Z command with resolved runtime state."""
+
def __init__(self, helper, gcmd):
self.helper = helper
self.gcmd = gcmd
self.gcode = helper.gcode
self.z_endstop = helper.z_endstop
- self.probe = helper.printer.lookup_object('probe')
- self.toolhead = helper.printer.lookup_object('toolhead')
- self.gcode_move = helper.printer.lookup_object('gcode_move')
- self.max_deviation = helper.max_deviation
+ self.objects_compat = helper.objects_compat
+ self.probe = self.objects_compat.lookup_probe()
+ self.probe_compat = ProbeCompat(helper, self.probe, gcmd)
+ self.toolhead_compat = ToolheadCompat(helper.printer)
+ if helper.offset_gcode is None:
+ gcode_move = self.objects_compat.lookup_gcode_move()
+ else:
+ gcode_move = None
+ self.gcode_offset = GCodeOffsetCompat(self.gcode, gcode_move,
+ helper.offset_gcode)
self.offset_margins = helper.offset_margins
def _probe_on_site(self, endstop, site, check_probe=False, split_xy=False,
wiggle=False):
- pos = self.toolhead.get_position()
+ """Move to a site and sample the given endstop with retry handling."""
+ pos = self.toolhead_compat.get_position()
self.helper._move_safe_z(pos, self.helper.lift_speed)
# move to position
if split_xy:
@@ -419,10 +465,7 @@ def _probe_on_site(self, endstop, site, check_probe=False, split_xy=False,
self.helper._move(site, self.helper.speed)
if check_probe:
# check if probe is attached and switch is closed
- time = self.toolhead.get_last_move_time()
- if self.probe.mcu_probe.query_endstop(time):
- raise self.gcmd.error("%s: probe switch not closed - probe not"
- " attached?" % (self.gcmd.get_command()))
+ self._check_probe_attached()
if self.helper.first_fast:
# first probe just to get down faster
self.helper._probe(self.gcmd, endstop, self.helper.position_min,
@@ -451,25 +494,40 @@ def _probe_on_site(self, endstop, site, check_probe=False, split_xy=False,
if self.helper.samples_result == 'median':
return self.helper._calc_median(positions)[2]
return self.helper._calc_mean(positions)[2]
+ def _probe_bed_on_site(self, site):
+ """Probe the bed using the Klipper probe session path."""
+ pos = self.toolhead_compat.get_position()
+ self.helper._move_safe_z(pos, self.helper.lift_speed)
+ self.helper._move(site, self.helper.speed)
+ self._check_probe_attached()
+ if self.helper.first_fast:
+ self.probe_compat.run_probe(self.helper.probing_speed, samples=1)
+ probe_result = self.probe_compat.run_probe(self.helper.second_speed)
+ curpos = self.probe_compat.get_test_position(probe_result)
+ self.gcode.respond_info("%s: probe at %.3f,%.3f is z=%.6f"
+ % (self.gcmd.get_command(), curpos[0],
+ curpos[1], curpos[2]))
+ return curpos[2]
+ def _check_probe_attached(self):
+ """Verify the detachable probe switch is not already triggered."""
+ time = self.toolhead_compat.get_last_move_time()
+ if self.probe_compat.query_endstop(time):
+ raise self.gcmd.error("%s: probe switch not closed - probe not"
+ " attached?" % (self.gcmd.get_command()))
def _add_probe_offset(self, site):
+ """Convert a nozzle XY site to the matching probe XY site."""
# calculate bed position by using the probe's offsets
- probe_offsets = self.probe.get_offsets()
+ probe_offsets = self.probe_compat.get_offsets()
probe_site = list(site)
probe_site[0] -= probe_offsets[0]
probe_site[1] -= probe_offsets[1]
return probe_site
def _set_new_gcode_offset(self, offset):
- # reset gcode z offset to 0
- gcmd_offset = self.gcode.create_gcode_command("SET_GCODE_OFFSET",
- "SET_GCODE_OFFSET",
- {'Z': 0.0})
- self.gcode_move.cmd_SET_GCODE_OFFSET(gcmd_offset)
- # set new gcode z offset
- gcmd_offset = self.gcode.create_gcode_command("SET_GCODE_OFFSET",
- "SET_GCODE_OFFSET",
- {'Z_ADJUST': offset})
- self.gcode_move.cmd_SET_GCODE_OFFSET(gcmd_offset)
+ """Apply the newly calculated Z offset through Klipper."""
+ self.gcode_offset.set_new_offset(offset)
+
def calibrate_z(self, switch_offset, nozzle_site, switch_site, bed_site):
+ """Run the complete calibration sequence and store the result."""
# execute start gcode
self.helper.start_gcode.run_gcode_from_command()
try:
@@ -482,37 +540,34 @@ def calibrate_z(self, switch_offset, nozzle_site, switch_site, bed_site):
# execute switch gcode
self.helper.switch_gcode.run_gcode_from_command()
# start probe session
- # TODO: remove: deprecated since 2024-06-10
- if hasattr(self.probe, 'multi_probe_begin'):
- self.probe.multi_probe_begin()
- else:
- self.probe.probe_session.start_probe_session(None)
+ self.probe_compat.start()
try:
# probe switch body
switch_zero = self._probe_on_site(self.z_endstop,
switch_site,
check_probe=True)
- # probe bed position
+ # Probe bed position. Keep the raw trigger Z here, equivalent
+ # to modern ProbeResult.test_z. Do not use ProbeResult.bed_z:
+ # bed_z subtracts the configured probe z_offset and would
+ # shift this calibration formula by that amount.
probe_site = self._add_probe_offset(bed_site)
- # TODO: remove: deprecated since 2026-05-25
- # Klipper's probe refactor nests the real MCU endstop inside
- # ProbeEndstopWrapper, which itself no longer exposes
- # get_steppers/home_start/etc. Unwrap when needed.
- probe_endstop = self.probe.mcu_probe
- if not hasattr(probe_endstop, 'get_steppers'):
- probe_endstop = probe_endstop.mcu_endstop
- probe_zero = self._probe_on_site(probe_endstop,
- probe_site,
- check_probe=True)
+ if self.probe_compat.can_probe():
+ probe_zero = self._probe_bed_on_site(probe_site)
+ else:
+ probe_endstop = (
+ self.probe_compat.get_legacy_probe_endstop())
+ if probe_endstop is None:
+ raise self.gcmd.error(
+ "%s: probe does not expose an MCU endstop"
+ % (self.gcmd.get_command(),))
+ probe_zero = self._probe_on_site(probe_endstop,
+ probe_site,
+ check_probe=True)
finally:
# end probe session
try:
- # TODO: remove: deprecated since 2024-06-10
- if hasattr(self.probe, 'multi_probe_end'):
- self.probe.multi_probe_end()
- else:
- self.probe.probe_session.end_probe_session()
- except:
+ self.probe_compat.end()
+ except Exception:
logging.exception("Multi-probe end")
# calculate the offset
offset = probe_zero - (switch_zero - nozzle_zero + switch_offset)
@@ -534,15 +589,8 @@ def calibrate_z(self, switch_offset, nozzle_site, switch_site, bed_site):
pos_z_estop, offset,
new_pos_z_estop))
# check offset margins
- # TODO: remove: max_deviation is deprecated
- if (self.max_deviation is not None
- and abs(offset) > self.max_deviation):
- raise self.gcmd.error("%s: offset is greater than allowed:"
- " offset=%.3f > max_deviation=%.3f"
- % (self.gcmd.get_command(), offset,
- self.max_deviation))
- elif (offset < self.offset_margins[0]
- or offset > self.offset_margins[1]):
+ if (offset < self.offset_margins[0]
+ or offset > self.offset_margins[1]):
raise self.gcmd.error("%s: offset %.3f is outside the"
" configured range of min=%.3f and"
" max=%.3f"
@@ -558,4 +606,5 @@ def calibrate_z(self, switch_offset, nozzle_site, switch_site, bed_site):
# execute end gcode
self.helper.end_gcode.run_gcode_from_command()
def load_config(config):
+ """Klipper entrypoint used to instantiate the plugin."""
return ZCalibrationHelper(config)