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 @@

- Z-Calibration Logo + + + + Automatic Z-Calibration +

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: [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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)